Files
nexus/frontend/src/views/TaskBoardView.vue
T
devops adae7ba26d
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s
feat: ship agent progress visibility
2026-06-20 20:22:54 +02:00

1168 lines
59 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* TaskBoardView Linear-style Kanban Board
* Galaxy/Dashboard V2 styled edition.
*
* 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, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useTaskStore } from '../stores/tasks'
type BoardTask = ReturnType<typeof flattenBoard>[number]
type TaskFormState = {
title: string
detail: string
priority: string
assignedTo: string
state: string
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)
const detailError = ref('')
const detailSuccess = ref('')
const detailLoading = ref(false)
const childTasks = ref<BoardTask[]>([])
const taskActivity = ref<Array<{ id?: string; message?: string; type?: string; createdAt?: string; timestamp?: string }>>([])
/* ── Create Task Form ───────────────────────────── */
const formTitle = ref('')
const formDetail = ref('')
const formPriority = ref('Medium')
const formAssignedTo = ref('bao')
const formSubmitting = ref(false)
const formError = ref('')
const detailForm = reactive<TaskFormState>({
title: '',
detail: '',
priority: 'Medium',
assignedTo: 'bao',
state: 'Backlog',
dueDate: '',
})
function resetForm() {
formTitle.value = ''
formDetail.value = ''
formPriority.value = 'Medium'
formAssignedTo.value = 'bao'
formError.value = ''
}
async function handleCreateTask() {
if (!formTitle.value.trim()) {
formError.value = 'Titel ist erforderlich'
return
}
formSubmitting.value = true
formError.value = ''
try {
await taskStore.createTask({
title: formTitle.value.trim(),
detail: formDetail.value.trim() || null,
priority: formPriority.value,
assignedTo: formAssignedTo.value,
})
showCreateModal.value = false
resetForm()
} catch (_err) {
formError.value = 'Fehler beim Erstellen der Aufgabe'
} finally {
formSubmitting.value = false
}
}
/* ── Drag & Drop ────────────────────────────────── */
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'
e.dataTransfer.setData('text/plain', taskId)
const el = e.currentTarget as HTMLElement | null
el?.classList.add('dragging')
}
function onDragEnd(e: DragEvent) {
draggedTaskId.value = null
dragOverColumn.value = null
const el = e.currentTarget as HTMLElement | null
el?.classList.remove('dragging')
}
function onDragOver(e: DragEvent, column: string) {
if (!canChangeState.value) return
e.preventDefault()
if (!e.dataTransfer) return
e.dataTransfer.dropEffect = 'move'
dragOverColumn.value = column
}
function onDragLeave(_e: DragEvent) {
dragOverColumn.value = null
}
async function onDrop(e: DragEvent, targetState: string) {
if (!canChangeState.value) return
e.preventDefault()
dragOverColumn.value = null
const taskId = e.dataTransfer?.getData('text/plain')
if (!taskId) return
await taskStore.moveTask(taskId, targetState)
}
/* ── Helpers ──────────────────────────────────────── */
function priorityLabel(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high') return 'High'
if (lower === 'low') return 'Low'
return 'Med'
}
function priorityColor(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high') return '#f87171'
if (lower === 'low') return '#60a5fa'
return '#facc15'
}
function statusTone(state: string): string {
switch (state.toLowerCase()) {
case 'done': return 'is-done'
case 'blocked': return 'is-blocked'
case 'review': return 'is-review'
case 'delegated': return 'is-delegated'
case 'in progress': return 'is-progress'
default: return 'is-backlog'
}
}
function stateLabel(state: string): string {
return state === 'Backlog' ? 'Offen' : state
}
function formatDate(date?: string | null, withTime = false): string {
if (!date) return '—'
return new Date(date).toLocaleString('de-DE', withTime
? { dateStyle: 'medium', timeStyle: 'short' }
: { dateStyle: 'medium' })
}
function toDateInputValue(date?: string | null): string {
if (!date) return ''
return new Date(date).toISOString().slice(0, 10)
}
function flattenBoard() {
return [
...taskStore.board.offen,
...taskStore.board.inProgress,
...taskStore.board.delegated,
...taskStore.board.review,
...taskStore.board.blocked,
...taskStore.board.done,
]
}
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 = ''
if (!task) return
detailForm.title = task.title
detailForm.detail = task.detail ?? ''
detailForm.priority = task.priority || 'Medium'
detailForm.assignedTo = task.assignedTo || ''
detailForm.state = task.state || 'Backlog'
detailForm.dueDate = toDateInputValue(task.dueDate)
}
/* ── 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',
'researcher': '🔬 Researcher',
'executor': '⚡ Executor',
}
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)
}
function relativeTime(date?: string | null): string {
if (!date) return 'keine Updates'
const diffMs = Date.now() - new Date(date).getTime()
const mins = Math.max(0, Math.round(diffMs / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const hours = Math.round(mins / 60)
if (hours < 24) return `vor ${hours} h`
const days = Math.round(hours / 24)
return `vor ${days} d`
}
function activityHint(task: BoardTask): string {
return task.lastActivityMessage?.trim() || (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status')
}
/* ── 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
childTasks.value = []
taskActivity.value = []
detailError.value = ''
detailSuccess.value = ''
}
async function loadDetailContext(taskId: string) {
detailLoading.value = true
detailError.value = ''
try {
const [children, activity] = await Promise.all([
taskStore.fetchTaskChildren(taskId),
taskStore.fetchTaskActivity(taskId),
])
childTasks.value = children
taskActivity.value = activity
} catch (_err) {
detailError.value = 'Zusätzliche Details konnten nicht vollständig geladen werden'
} finally {
detailLoading.value = false
}
}
async function saveTaskDetail() {
if (!selectedTask.value || !detailForm.title.trim()) return
detailSaving.value = true
detailError.value = ''
detailSuccess.value = ''
try {
await taskStore.updateTask(selectedTask.value.id, {
title: detailForm.title.trim(),
detail: detailForm.detail.trim() || null,
priority: detailForm.priority,
assignedTo: detailForm.assignedTo || null,
dueDate: detailForm.dueDate || null,
})
// 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'
await loadDetailContext(selectedTask.value.id)
} catch (_err) {
detailError.value = 'Änderungen konnten nicht gespeichert werden'
} finally {
detailSaving.value = false
}
}
function onGlobalKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && showDetailPanel.value) {
closeDetailPanel()
}
}
watch(selectedTask, (task) => {
hydrateDetailForm(task)
})
watch(showDetailPanel, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
/* ── Lifecycle ────────────────────────────────────── */
onMounted(() => {
taskStore.startBoardPolling()
taskStore.fetchAgentOverview()
window.addEventListener('keydown', onGlobalKeydown)
// Refresh agent overview on the same interval
setInterval(() => taskStore.fetchAgentOverview(), 30000)
})
onBeforeUnmount(() => {
document.body.style.overflow = ''
})
onUnmounted(() => {
taskStore.stopBoardPolling()
window.removeEventListener('keydown', onGlobalKeydown)
})
</script>
<template>
<div class="board-wrap">
<div class="board-header">
<div>
<h1><span class="grad-text">Aufgaben</span></h1>
<p class="board-subtitle">Task Board Übersicht aller Arbeitspakete</p>
</div>
<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 progress-row">
<div>
<span class="iris-task-title">{{ t.title }}</span>
<div class="iris-task-progress">{{ activityHint(t) }}</div>
</div>
<span class="iris-task-meta">{{ relativeTime(t.lastActivityAt ?? t.updatedAt) }}</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 progress-row">
<div>
<span class="iris-task-title">{{ t.title }}</span>
<div class="iris-task-progress">{{ activityHint(t) }}</div>
</div>
<span class="iris-task-meta">{{ relativeTime(t.lastActivityAt ?? t.updatedAt) }}</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 progress-row">
<div>
<span class="iris-task-title">{{ t.title }}</span>
<div class="iris-task-progress">{{ activityHint(t) }}</div>
</div>
<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 progress-row">
<div>
<span class="iris-task-title">{{ t.title }}</span>
<div class="iris-task-progress">{{ activityHint(t) }}</div>
</div>
<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">
<div class="spinner"></div>
<span>Lade Aufgaben</span>
</div>
<div v-else class="board-columns">
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'offen' }"
@dragover="onDragOver($event, 'offen')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'offen')"
>
<div class="col-header">
<span class="col-icon" style="background: var(--st-queue); box-shadow: 0 0 0 2px rgba(251,191,36,.25);"></span>
<span class="col-name">Offen</span>
<span class="col-count">{{ taskStore.board.offen.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.offen"
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<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"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.offen.length" class="empty-col">Keine Aufgaben</div>
</div>
</div>
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'inProgress' }"
@dragover="onDragOver($event, 'inProgress')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'inProgress')"
>
<div class="col-header">
<span class="col-icon" style="background: var(--st-work); box-shadow: 0 0 0 2px rgba(61,220,151,.25);"></span>
<span class="col-name">In Bearbeitung</span>
<span class="col-count">{{ taskStore.board.inProgress.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.inProgress"
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<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"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.inProgress.length" class="empty-col">Keine Aufgaben</div>
</div>
</div>
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'delegated' }"
@dragover="onDragOver($event, 'delegated')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'delegated')"
>
<div class="col-header">
<span class="col-icon" style="background: var(--a-purple); box-shadow: 0 0 0 2px rgba(181,87,246,.25);"></span>
<span class="col-name">Delegiert</span>
<span class="col-count">{{ taskStore.board.delegated.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.delegated"
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<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"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.delegated.length" class="empty-col">Keine delegierten Aufgaben</div>
</div>
</div>
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'review' }"
@dragover="onDragOver($event, 'review')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'review')"
>
<div class="col-header">
<span class="col-icon" style="background: #fb923c; box-shadow: 0 0 0 2px rgba(251,146,60,.25);"></span>
<span class="col-name">Review</span>
<span class="col-count">{{ taskStore.board.review.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.review"
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<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"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.review.length" class="empty-col">Keine Aufgaben</div>
</div>
</div>
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'done' }"
@dragover="onDragOver($event, 'done')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'done')"
>
<div class="col-header">
<span class="col-icon" style="background: var(--st-work); box-shadow: 0 0 0 2px rgba(61,220,151,.25);"></span>
<span class="col-name">Erledigt</span>
<span class="col-count">{{ taskStore.board.done.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.done"
:key="task.id"
type="button"
class="card"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: 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 v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.done.length" class="empty-col">Keine Aufgaben</div>
</div>
</div>
<div
class="col col-blocked"
:class="{ 'drag-over': dragOverColumn === 'blocked' }"
@dragover="onDragOver($event, 'blocked')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'blocked')"
>
<div class="col-header">
<span class="col-icon" style="background: var(--st-block); box-shadow: 0 0 0 2px rgba(251,113,133,.25);"></span>
<span class="col-name">Blockiert</span>
<span class="col-count">{{ taskStore.board.blocked.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.blocked"
:key="task.id"
type="button"
class="card card-blocked"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<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"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.blocked.length" class="empty-col">Keine Blockierer</div>
</div>
</div>
</div>
<Teleport to="body">
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
<div class="modal-card">
<div class="modal-header">
<h2>Neue Aufgabe</h2>
<button class="modal-close" @click="showCreateModal = false">&times;</button>
</div>
<form @submit.prevent="handleCreateTask" class="modal-form">
<div class="field">
<label for="task-title">Titel <span class="req">*</span></label>
<input
id="task-title"
v-model="formTitle"
type="text"
class="field-input"
placeholder="Aufgabe beschreiben…"
/>
</div>
<div class="field">
<label for="task-detail">Beschreibung</label>
<textarea
id="task-detail"
v-model="formDetail"
class="field-input field-textarea"
placeholder="Details zur Aufgabe…"
rows="3"
></textarea>
</div>
<div class="field-row">
<div class="field">
<label for="task-priority">Priorität</label>
<select id="task-priority" v-model="formPriority" class="field-input field-select">
<option value="High">High</option>
<option value="Medium">Medium</option>
<option value="Low">Low</option>
</select>
</div>
<div class="field">
<label for="task-assignee">Zugewiesen an</label>
<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>
<option value="researcher">🔬 Researcher</option>
<option value="executor"> Executor</option>
</select>
</div>
</div>
<p v-if="formError" class="form-error">{{ formError }}</p>
<div class="modal-actions">
<button type="button" class="btn-cancel" @click="showCreateModal = false">Abbrechen</button>
<button type="submit" class="btn-submit" :disabled="formSubmitting">
{{ formSubmitting ? 'Erstelle…' : 'Aufgabe erstellen' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="showDetailPanel && selectedTask" class="detail-overlay" @click.self="closeDetailPanel">
<aside class="detail-panel">
<div class="detail-topbar">
<div class="detail-breadcrumb">Task Board / {{ stateLabel(selectedTask.state) }}</div>
<button type="button" class="detail-close" @click="closeDetailPanel" aria-label="Detailansicht schließen">
<X :size="18" />
</button>
</div>
<div class="detail-content">
<section class="detail-main">
<div class="detail-title-block">
<span class="detail-state-pill" :class="statusTone(selectedTask.state)">{{ stateLabel(selectedTask.state) }}</span>
<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>
<span v-if="selectedTask.isAgentTask"><MessageSquareText :size="13" /> Letzter Status {{ relativeTime(selectedTask.lastActivityAt ?? selectedTask.updatedAt) }}</span>
</div>
<div v-if="selectedTask.isAgentTask" class="detail-progress-banner">
<strong>Letzter Fortschritt:</strong> {{ activityHint(selectedTask) }}
</div>
</div>
<div class="detail-section">
<div class="detail-section-header">Beschreibung</div>
<textarea
v-model="detailForm.detail"
class="detail-textarea"
rows="8"
placeholder="Mehr Kontext, Akzeptanzkriterien oder Notizen…"
></textarea>
</div>
<div class="detail-grid">
<section class="detail-section">
<div class="detail-section-header"><ListChecks :size="14" /> Unteraufgaben</div>
<div v-if="detailLoading" class="detail-empty">Lade</div>
<div v-else-if="childTasks.length" class="detail-stack">
<article v-for="child in childTasks" :key="child.id" class="mini-card">
<div class="mini-card-row">
<span class="mini-title">{{ child.title }}</span>
<span class="mini-state">{{ stateLabel(child.state) }}</span>
</div>
<p v-if="child.detail" class="mini-copy">{{ child.detail }}</p>
</article>
</div>
<div v-else class="detail-empty">Noch keine Unteraufgaben verknüpft.</div>
</section>
<section class="detail-section">
<div class="detail-section-header"><Link2 :size="14" /> Aktivität</div>
<div v-if="detailLoading" class="detail-empty">Lade</div>
<div v-else-if="taskActivity.length" class="activity-list">
<article v-for="(entry, index) in taskActivity" :key="entry.id ?? index" class="activity-item">
<div class="activity-dot"></div>
<div>
<div class="activity-message">{{ entry.message ?? 'Aktivität' }}</div>
<div class="activity-time">{{ formatDate(entry.createdAt ?? entry.timestamp ?? null, true) }}</div>
</div>
</article>
</div>
<div v-else class="detail-empty">Noch keine Aktivität vorhanden.</div>
</section>
</div>
</section>
<aside class="detail-sidebar">
<section class="sidebar-card">
<div class="sidebar-heading">Eigenschaften</div>
<label class="sidebar-field">
<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>
<option value="Review">Review</option>
<option value="Blocked">Blockiert</option>
<option value="Done">Erledigt</option>
</select>
</label>
<label class="sidebar-field">
<span>Priorität</span>
<select v-model="detailForm.priority" class="field-input field-select slim">
<option value="High">High</option>
<option value="Medium">Medium</option>
<option value="Low">Low</option>
</select>
</label>
<label class="sidebar-field">
<span>Zuständig</span>
<select v-model="detailForm.assignedTo" class="field-input field-select slim">
<option value="">Nicht zugewiesen</option>
<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>
<option value="researcher">🔬 Researcher</option>
<option value="executor"> Executor</option>
</select>
</label>
<label class="sidebar-field">
<span>Fällig am</span>
<input v-model="detailForm.dueDate" type="date" class="field-input slim" />
</label>
</section>
<section class="sidebar-card subtle">
<div class="sidebar-heading">Snapshot</div>
<dl class="snapshot-list">
<div>
<dt>Quelle</dt>
<dd>{{ selectedTask.source || '—' }}</dd>
</div>
<div>
<dt>Priorität</dt>
<dd>{{ selectedTask.priority }}</dd>
</div>
<div>
<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>
<p v-if="detailError" class="detail-flash error">{{ detailError }}</p>
<p v-else-if="detailSuccess" class="detail-flash success">{{ detailSuccess }}</p>
<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 öffnen
</button>
<button type="button" class="btn-submit" :disabled="!canSaveDetail || (detailForm.state !== selectedTask.state && !canChangeState)" @click="saveTaskDetail">
<Save :size="14" />
{{ detailSaving ? 'Speichert…' : 'Speichern' }}
</button>
</div>
</aside>
</div>
</aside>
</div>
</Teleport>
</div>
</template>
<style scoped>
.board-wrap {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
animation: board-fade-in 0.35s ease-out;
}
@keyframes board-fade-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.board-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.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; }
.progress-row { align-items: flex-start; }
.iris-task-title { font-size: 11.5px; font-weight: 500; color: var(--tx); display: block; }
.iris-task-progress { margin-top: 4px; font-size: 10px; color: var(--tx-3); line-height: 1.35; }
.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; }
.col-name { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--tx-2); font-family: 'Space Grotesk', sans-serif; }
.col-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; font-variant-numeric: tabular-nums; padding: 2px 8px; border-radius: 10px; background: var(--glass-2); color: var(--tx-2); border: 1px solid var(--line); }
.col-cards { display: flex; flex-direction: column; gap: 8px; flex: 1; }
.card { padding: 10px 12px; border-radius: var(--r-sm, 10px); background: linear-gradient(160deg, rgba(28,24,64,.45), rgba(20,17,48,.35)); border: 1px solid var(--line); cursor: pointer; transition: transform .15s, box-shadow .2s, border-color .15s, opacity .15s; text-align: left; width: 100%; }
.card:hover { transform: scale(1.02); border-color: var(--line-2); box-shadow: 0 0 0 1px rgba(124,108,255,.10), 0 8px 24px -6px rgba(0,0,0,.4); }
.card:active { cursor: grabbing; }
.card.dragging { opacity: .4; cursor: grabbing; }
.card-blocked { border-left: 3px solid var(--st-block); }
.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; }
.card-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; }
.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-progress-hint { margin-top: 7px; font-size: 10.5px; color: var(--tx-2); line-height: 1.4; padding: 6px 8px; border-radius: 8px; background: rgba(124,108,255,.07); border: 1px solid rgba(124,108,255,.12); }
.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); }
.modal-header h2 { margin: 0; font-size: 16px; font-weight: 700; color: var(--tx); font-family: 'Space Grotesk', sans-serif; }
.modal-close, .detail-close { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border: none; background: transparent; color: var(--tx-3); cursor: pointer; border-radius: 6px; transition: background .15s, color .15s; }
.modal-close { font-size: 20px; }
.modal-close:hover, .detail-close:hover { background: rgba(124,108,255,.10); color: var(--tx); }
.modal-form { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 5px; flex: 1; }
.field label, .sidebar-field span { font-size: 10.5px; font-weight: 600; color: var(--tx-2); text-transform: uppercase; letter-spacing: .04em; font-family: 'Manrope', sans-serif; }
.req { color: var(--st-block); }
.field-input { width: 100%; padding: 8px 12px; border: 1px solid var(--line); border-radius: var(--r-sm, 10px); background: rgba(14,12,32,.5); color: var(--tx); font-size: 13px; font-family: 'Manrope', sans-serif; outline: none; transition: border-color .15s, box-shadow .15s; box-sizing: border-box; }
.field-input:focus, .detail-title-input:focus, .detail-textarea:focus { border-color: var(--a-mid); box-shadow: 0 0 0 2px rgba(124,108,255,.15); }
.field-textarea, .detail-textarea { resize: vertical; min-height: 60px; font-family: inherit; }
.field-select { cursor: pointer; }
.field-row { display: flex; gap: 12px; }
.form-error, .detail-flash.error { color: var(--st-block); font-size: 11px; margin: 0; font-family: 'Manrope', sans-serif; }
.detail-flash.success { color: #86efac; font-size: 11px; margin: 0; }
.modal-actions, .detail-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.btn-cancel { padding: 7px 14px; border: 1px solid var(--line); border-radius: var(--r-sm, 10px); background: transparent; color: var(--tx-2); font-size: 12px; font-weight: 500; font-family: 'Manrope', sans-serif; cursor: pointer; transition: background .15s, color .15s; }
.btn-cancel:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.btn-ghost { padding: 7px 14px; border: 1px solid var(--line); border-radius: var(--r-sm, 10px); background: transparent; color: var(--tx-2); font-size: 12px; font-weight: 500; font-family: 'Manrope', sans-serif; cursor: pointer; transition: background .15s, color .15s; display: inline-flex; align-items: center; gap: 6px; }
.btn-ghost:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.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); }
.detail-content { display: grid; grid-template-columns: minmax(0, 1fr) 320px; min-height: 0; flex: 1; }
.detail-main { padding: 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
.detail-sidebar { padding: 24px; border-left: 1px solid var(--line); background: rgba(255,255,255,.02); display: flex; flex-direction: column; gap: 14px; }
.detail-title-block { display: flex; flex-direction: column; gap: 12px; }
.detail-state-pill { width: fit-content; border-radius: 999px; padding: 5px 10px; font-size: 11px; font-weight: 700; letter-spacing: .03em; border: 1px solid transparent; }
.detail-state-pill.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
.detail-state-pill.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.detail-state-pill.is-delegated { color: #d8b4fe; background: rgba(168,85,247,.12); border-color: rgba(168,85,247,.25); }
.detail-state-pill.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
.detail-state-pill.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
.detail-state-pill.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.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-progress-banner { padding: 10px 12px; border-radius: 12px; background: rgba(124,108,255,.08); border: 1px solid rgba(124,108,255,.14); color: var(--tx-2); font-size: 12px; }
.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; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.detail-stack, .activity-list { display: flex; flex-direction: column; gap: 10px; }
.mini-card { border: 1px solid var(--line); border-radius: 12px; padding: 12px; background: rgba(10,9,24,.45); }
.mini-card-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.mini-title { font-size: 13px; font-weight: 600; color: var(--tx); }
.mini-state { font-size: 10px; color: var(--tx-3); text-transform: uppercase; letter-spacing: .05em; }
.mini-copy { margin: 8px 0 0; font-size: 11.5px; color: var(--tx-2); line-height: 1.5; }
.activity-item { display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 10px; align-items: start; }
.activity-dot { width: 8px; height: 8px; border-radius: 999px; margin-top: 5px; background: linear-gradient(135deg, #8b5cf6, #3b82f6); box-shadow: 0 0 0 4px rgba(124,108,255,.12); }
.activity-message { color: var(--tx); font-size: 12.5px; line-height: 1.45; }
.activity-time { color: var(--tx-3); font-size: 10.5px; margin-top: 4px; }
.sidebar-card { border: 1px solid var(--line); border-radius: 16px; padding: 16px; background: rgba(10,9,24,.45); }
.sidebar-card.subtle { background: rgba(255,255,255,.02); }
.sidebar-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.sidebar-field:last-child { margin-bottom: 0; }
.slim { min-height: 38px; }
.snapshot-list { display: flex; flex-direction: column; gap: 12px; margin: 0; }
.snapshot-list div { display: flex; justify-content: space-between; gap: 12px; }
.snapshot-list dt { color: var(--tx-3); font-size: 11px; }
.snapshot-list dd { margin: 0; color: var(--tx); font-size: 12px; text-align: right; }
.board-columns::-webkit-scrollbar { height: 9px; }
.board-columns::-webkit-scrollbar-thumb { background: rgba(124,108,255,.22); border-radius: 9px; border: 2px solid transparent; background-clip: padding-box; }
.board-columns::-webkit-scrollbar-thumb:hover { background: rgba(124,108,255,.4); background-clip: padding-box; }
.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>