Files
nexus/frontend/src/views/TaskBoardView.vue
T
devops b82d88563a feat(ui): consolidate shared UI patterns into reusable components
- Token cleanup: replace raw hex colors (#c084fc, #60a5fa, #6ee7b7, #fdba74)
  in BoardCard.vue with nexus-tokens.css CSS variables (--clr-iris, --clr-bao,
  --clr-agent, --clr-review)
- New composable: useFormatDate (formatDate, relativeTime, toDateInputValue,
  minutesSince, hoursSince) — extracts duplicated date helpers from
  TaskBoardView and BoardCard
- New composable: useConfirm — reusable confirmation dialog logic
  (open/close, error/success state, Escape binding, body scroll lock)
- New component: StatusPill — unified state pill for backlog/progress/
  review/blocked/done, replacing inline .detail-state-pill classes
- New component: SkeletonLoader — loading placeholder with shimmer
  animation (card/text/circle variants)
- TaskBoardView: imports StatusPill & useFormatDate, removes 35+ lines
  of duplicated helpers
- ui/index.ts: exports new StatusPill & SkeletonLoader
- Build verified: pnpm build green (vue-tsc --noEmit + vite build pass)
2026-07-11 14:11:02 +02:00

1147 lines
61 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.
*
* 5 columns: Offen, In Bearbeitung, 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 > 2h)
* - Waiting section for Iris overview
*/
import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { Plus, X, ExternalLink, Save, AlertTriangle, Eye, Bot, ShieldBan, RotateCcw, Check, Copy, Send, Search } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useTaskStore, type DashboardTaskDto } from '../stores/tasks'
import { useLiveSyncStore } from '../stores/liveSync'
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
import BoardCard from '../components/board/BoardCard.vue'
import StatusPill from '../components/ui/StatusPill.vue'
import { formatDate, toDateInputValue, relativeTime, minutesSince, hoursSince } from '../composables/useFormatDate'
/** Schwelle (min) ohne Aktivität, ab der ein In-Bearbeitung-Task als „hängt" gilt.
* Spiegelt die Backend-Watchdog-Schwelle (TaskRecovery:StalledMinutes). */
const STALL_THRESHOLD_MIN = 40
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 liveSyncStore = useLiveSyncStore()
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
}
}
/* ── Review-Aktionen (Bao/Iris) ───────────────────── */
const reviewError = ref('')
const showChangesModal = ref(false)
const changesTask = ref<DashboardTaskDto | null>(null)
const changesComment = ref('')
const changesTarget = ref('In progress')
const changesSubmitting = ref(false)
async function handleApprove(id: string) {
reviewError.value = ''
try {
await taskStore.approveReview(id)
} catch (err) {
reviewError.value = err instanceof Error ? err.message : 'Abnahme fehlgeschlagen'
}
}
function openRequestChanges(task: DashboardTaskDto) {
changesTask.value = task
changesComment.value = ''
changesTarget.value = 'In progress'
reviewError.value = ''
showChangesModal.value = true
}
async function submitRequestChanges() {
if (!changesTask.value || !changesComment.value.trim()) return
changesSubmitting.value = true
reviewError.value = ''
try {
await taskStore.requestChanges(changesTask.value.id, changesComment.value.trim(), changesTarget.value)
showChangesModal.value = false
changesTask.value = null
} catch (err) {
reviewError.value = err instanceof Error ? err.message : 'Konnte nicht zurückgegeben werden'
} finally {
changesSubmitting.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 ──────────────────────────────────────── */
/** Map state string to StatusPill variant */
function statusPillVariant(state: string): 'backlog' | 'progress' | 'review' | 'blocked' | 'done' {
const s = state.toLowerCase()
if (s === 'done') return 'done'
if (s === 'blocked') return 'blocked'
if (s === 'review') return 'review'
if (s === 'in progress') return 'progress'
return 'backlog'
}
function stateLabel(state: string): string {
return state === 'Backlog' ? 'Offen' : state
}
function flattenBoard() {
const masters = [
...taskStore.board.offen,
...taskStore.board.inProgress,
...taskStore.board.review,
...taskStore.board.blocked,
...taskStore.board.done,
]
// Children sind keine eigenen Spalten-Karten mehr — fuer Quick-Peek
// (Klick auf Teilaufgabe) muessen sie hier trotzdem auffindbar sein.
return [...masters, ...masters.flatMap(m => m.childTasks ?? [])]
}
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)
/* ── Board-Filter (Linear-Style Toolbar) ─────────── */
const searchQuery = ref('')
const ballFilter = ref<'all' | 'bao' | 'iris' | 'stalled'>('all')
const ballFilters = [
{ key: 'all', label: 'Alle' },
{ key: 'bao', label: 'Du bist dran' },
{ key: 'iris', label: 'Bei Iris' },
{ key: 'stalled', label: 'Hängt' },
] as const
function isStalledTask(t: DashboardTaskDto): boolean {
if (t.state.toLowerCase() !== 'in progress') return false
return minutesSince(t.lastActivityAt ?? t.updatedAt) > STALL_THRESHOLD_MIN
}
/** Ball = wer als Naechstes handeln muss (gleiche Logik wie BoardCard). */
function ballOf(t: DashboardTaskDto): string | null {
const s = t.state.toLowerCase()
if (s === 'review') return 'bao'
if (s === 'done') return null
if (s === 'backlog') return t.expectedFrom || 'iris'
return t.expectedFrom || t.assignedTo || 'iris'
}
function matchesFilters(t: DashboardTaskDto): boolean {
const q = searchQuery.value.trim().toLowerCase()
if (q) {
const hay = [t.title, t.detail ?? '', t.id, ...(t.childTasks ?? []).map(c => c.title)]
.join(' ')
.toLowerCase()
if (!hay.includes(q)) return false
}
if (ballFilter.value === 'bao') return ballOf(t) === 'bao'
if (ballFilter.value === 'iris') return ballOf(t) === 'iris'
if (ballFilter.value === 'stalled') {
const children = t.childTasks ?? []
return children.length ? children.some(isStalledTask) : isStalledTask(t)
}
return true
}
function filterTasks(list: DashboardTaskDto[]): DashboardTaskDto[] {
return list.filter(matchesFilters)
}
/* Spalten-Konfiguration — Board zeigt nur Master-Tasks (Children nested in der Karte). */
const columns = computed(() => [
{ key: 'offen', name: 'Offen', tasks: filterTasks(taskStore.board.offen), dot: 'var(--st-queue)', ring: 'rgba(251,191,36,.25)' },
{ key: 'inProgress', name: 'In Bearbeitung', tasks: filterTasks(taskStore.board.inProgress), dot: 'var(--st-work)', ring: 'rgba(61,220,151,.25)' },
{ key: 'review', name: 'Review', tasks: filterTasks(taskStore.board.review), dot: 'var(--clr-other)', ring: 'rgba(251,146,60,.25)' },
{ key: 'done', name: 'Erledigt', tasks: filterTasks(taskStore.board.done), dot: 'var(--st-work)', ring: 'rgba(61,220,151,.25)' },
{ key: 'blocked', name: 'Blockiert', tasks: filterTasks(taskStore.board.blocked), dot: 'var(--st-block)', ring: 'rgba(251,113,133,.25)' },
])
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)
const liveModeLabel = computed(() => liveSyncStore.liveIndicatorLabel)
const liveModeClass = computed(() => `live-pill-${liveSyncStore.connectionHealth}`)
function expectedFromLabel(expected: string | null | undefined): string {
if (!expected) return ''
return TASK_AGENT_LABELS[expected.toLowerCase()] ?? expected
}
function childStatusSummary(taskId: string): string {
const children = allBoardTasks.value.filter(task => task.parentTaskId === taskId)
if (!children.length) return ''
const counts = {
inProgress: children.filter(task => task.state === 'In progress').length,
review: children.filter(task => task.state === 'Review').length,
blocked: children.filter(task => task.state === 'Blocked').length,
done: children.filter(task => task.state === 'Done').length,
}
const parts = [] as string[]
if (counts.inProgress) parts.push(`${counts.inProgress} in Arbeit`)
if (counts.review) parts.push(`${counts.review} im Review`)
if (counts.blocked) parts.push(`${counts.blocked} blockiert`)
if (counts.done) parts.push(`${counts.done} erledigt`)
return parts.length ? `Child-Tasks: ${parts.join(' · ')}` : `Child-Tasks: ${children.length}`
}
function activityHint(task: BoardTask): string {
const childSummary = childStatusSummary(task.id)
if (childSummary) return childSummary
return task.lastActivityMessage?.trim()
|| (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status')
}
function hasChildTasks(taskId: string): boolean {
return allBoardTasks.value.some(task => task.parentTaskId === taskId)
}
function delegationBadge(task: BoardTask): string | null {
if (task.childTaskCount && task.openChildTaskCount) return `${task.openChildTaskCount}/${task.childTaskCount} aktiv`
if (task.childTaskCount) return `${task.childTaskCount} Child-Tasks`
if (task.parentTaskId) return 'Child-Task'
if (task.isAgentTask || task.hasVisibleDelegation) return 'delegiert'
return null
}
/* ── 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 closeDetailPanel() {
commentText.value = ''
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
}
}
/* ── Detail-Modal: Kommentar, Link kopieren, Child-Summary ── */
const commentText = ref('')
const commentSending = ref(false)
const linkCopied = ref(false)
async function submitComment() {
const text = commentText.value.trim()
if (!text || !selectedTask.value || commentSending.value) return
commentSending.value = true
try {
await taskStore.postTaskActivity(selectedTask.value.id, text)
commentText.value = ''
taskActivity.value = await taskStore.fetchTaskActivity(selectedTask.value.id)
} catch (_err) {
detailError.value = 'Kommentar konnte nicht gespeichert werden'
} finally {
commentSending.value = false
}
}
function copyTaskLink() {
if (!selectedTask.value) return
navigator.clipboard?.writeText(`${window.location.origin}/tasks/${selectedTask.value.id}`)
linkCopied.value = true
setTimeout(() => { linkCopied.value = false }, 1500)
}
const childStats = computed(() => {
const list = childTasks.value
const done = list.filter(c => c.state === 'Done').length
const active = list.filter(c => c.state === 'In progress').length
const blocked = list.filter(c => c.state === 'Blocked').length
return { total: list.length, done, active, blocked, open: list.length - done - active - blocked }
})
const childAgentSummary = computed(() => {
const map = new Map<string, { done: number; total: number }>()
for (const c of childTasks.value) {
const key = c.assignedTo || 'unassigned'
const cur = map.get(key) ?? { done: 0, total: 0 }
cur.total++
if (c.state === 'Done') cur.done++
map.set(key, cur)
}
return [...map.entries()].map(([agent, stats]) => ({ agent, ...stats }))
})
const recentActivity = computed(() => taskActivity.value.slice(0, 8))
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 ────────────────────────────────────── */
let agentOverviewInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
taskStore.startBoardPolling()
taskStore.fetchAgentOverview()
window.addEventListener('keydown', onGlobalKeydown)
agentOverviewInterval = setInterval(() => taskStore.fetchAgentOverview(), 30000)
})
onBeforeUnmount(() => {
document.body.style.overflow = ''
})
onUnmounted(() => {
taskStore.stopBoardPolling()
if (agentOverviewInterval) clearInterval(agentOverviewInterval)
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 class="board-live-row">
<span class="live-pill" :class="liveModeClass">{{ liveModeLabel }}</span>
<span v-if="liveSyncStore.lastEventAt" class="live-meta">Letztes Event {{ relativeTime(liveSyncStore.lastEventAt) }}</span>
</div>
</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>
<!-- Toolbar: Suche + Ball-Filter -->
<div class="board-toolbar">
<div class="tb-search">
<Search :size="14" />
<input v-model="searchQuery" type="text" placeholder="Tasks durchsuchen…" />
<button v-if="searchQuery" class="tb-clear" aria-label="Suche leeren" @click="searchQuery = ''">&times;</button>
</div>
<div class="tb-chips">
<button
v-for="f in ballFilters"
:key="f.key"
:class="['tb-chip', { active: ballFilter === f.key }]"
@click="ballFilter = f.key"
>{{ f.label }}</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 (In Bearbeitung &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
v-for="col in columns"
:key="col.key"
class="col"
:class="{ 'drag-over': dragOverColumn === col.key, 'col-blocked': col.key === 'blocked' }"
@dragover="onDragOver($event, col.key)"
@dragleave="onDragLeave"
@drop="onDrop($event, col.key)"
>
<div class="col-header">
<span class="col-icon" :style="{ background: col.dot, boxShadow: `0 0 0 2px ${col.ring}` }"></span>
<span class="col-name">{{ col.name }}</span>
<span class="col-count">{{ col.tasks.length }}</span>
</div>
<div class="col-cards">
<BoardCard
v-for="task in col.tasks"
:key="task.id"
:task="task"
:column="col.key"
:can-review="canChangeState"
:stall-threshold-min="STALL_THRESHOLD_MIN"
@open="openQuickPeek"
@approve="handleApprove"
@request-changes="openRequestChanges"
@dragstart="onDragStart"
@dragend="onDragEnd"
/>
<div v-if="!col.tasks.length" class="empty-col">Keine Aufgaben</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
v-for="option in TASK_AGENT_OPTIONS.filter(entry => entry.id)"
:key="option.id"
:value="option.id"
>
{{ option.label }}
</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="showChangesModal && changesTask" class="modal-overlay" @click.self="showChangesModal = false">
<div class="modal-card">
<div class="modal-header">
<h2><RotateCcw :size="16" /> Änderung anfordern</h2>
<button class="modal-close" @click="showChangesModal = false">&times;</button>
</div>
<form @submit.prevent="submitRequestChanges" class="modal-form">
<p class="changes-task-title">{{ changesTask.title }}</p>
<div class="field">
<label for="changes-comment">Was soll geändert werden? <span class="req">*</span></label>
<textarea
id="changes-comment"
v-model="changesComment"
class="field-input field-textarea"
placeholder="Konkretes Feedback für Iris — sie arbeitet autonom daran weiter…"
rows="4"
></textarea>
</div>
<div class="field">
<label for="changes-target">Zurück nach</label>
<select id="changes-target" v-model="changesTarget" class="field-input field-select">
<option value="In progress">In Bearbeitung</option>
<option value="Backlog">Offen</option>
<option value="Blocked">Blockiert</option>
</select>
</div>
<p v-if="reviewError" class="form-error">{{ reviewError }}</p>
<div class="modal-actions">
<button type="button" class="btn-cancel" @click="showChangesModal = false">Abbrechen</button>
<button type="submit" class="btn-submit" :disabled="changesSubmitting || !changesComment.trim()">
{{ changesSubmitting ? 'Sende…' : 'Zurückgeben' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="showDetailPanel && selectedTask" class="detail-overlay" @click.self="closeDetailPanel">
<aside class="detail-panel">
<!-- Topbar: Breadcrumb + ID + Aktionen -->
<header class="detail-topbar">
<div class="detail-crumb">
<span class="crumb-root">Task Board</span>
<span class="crumb-sep">/</span>
<StatusPill :variant="statusPillVariant(selectedTask.state)">{{ stateLabel(selectedTask.state) }}</StatusPill>
<button class="id-chip" :title="linkCopied ? 'Link kopiert!' : 'Link kopieren'" @click="copyTaskLink">
#{{ selectedTask.id.slice(0, 8) }}
<Check v-if="linkCopied" :size="11" />
<Copy v-else :size="11" />
</button>
</div>
<div class="detail-topbar-actions">
<template v-if="selectedTask.state === 'Review' && canChangeState">
<button class="btn btn-approve" @click="handleApprove(selectedTask.id); closeDetailPanel()"><Check :size="13" /> Abnehmen</button>
<button class="btn btn-changes" @click="openRequestChanges(selectedTask)"><RotateCcw :size="13" /> Änderung</button>
</template>
<button class="btn btn-ghost" @click="router.push('/tasks/' + selectedTask.id)"><ExternalLink :size="13" /> Vollansicht</button>
<button class="icon-btn" aria-label="Schließen" @click="closeDetailPanel"><X :size="16" /></button>
</div>
</header>
<div class="detail-content">
<!-- Hauptspalte: Titel, Chips, Beschreibung, Summary-Kacheln -->
<section class="detail-main v2-scroll">
<input v-model="detailForm.title" class="detail-title-input" maxlength="240" placeholder="Titel…" />
<div class="detail-chips">
<span v-if="ballOf(selectedTask)" class="meta-chip" :class="'ball-' + ballOf(selectedTask)">Ball: {{ expectedFromLabel(ballOf(selectedTask)) }}</span>
<span v-if="selectedTask.parentTaskId" class="meta-chip">Teilaufgabe</span>
<span v-else-if="selectedTask.isAgentTask" class="meta-chip">Agent-Task</span>
<span class="meta-chip dim">Erstellt {{ formatDate(selectedTask.createdAt) }}</span>
<span class="meta-chip dim">Update {{ relativeTime(selectedTask.lastActivityAt ?? selectedTask.updatedAt) }}</span>
</div>
<textarea
v-model="detailForm.detail"
class="detail-desc"
rows="5"
placeholder="Beschreibung, Kontext, Akzeptanzkriterien…"
></textarea>
<div v-if="detailLoading" class="detail-empty">Lade</div>
<template v-else-if="childStats.total">
<div class="tile-grid">
<div class="tile">
<span class="tile-num">{{ childStats.done }}<small>/{{ childStats.total }}</small></span>
<span class="tile-label">Fertig</span>
<div class="tile-track"><div class="tile-fill" :style="{ width: Math.round(childStats.done / childStats.total * 100) + '%' }"></div></div>
</div>
<div class="tile"><span class="tile-num t-active">{{ childStats.active }}</span><span class="tile-label">Aktiv</span></div>
<div class="tile"><span class="tile-num t-open">{{ childStats.open }}</span><span class="tile-label">Offen</span></div>
<div class="tile"><span class="tile-num t-blocked">{{ childStats.blocked }}</span><span class="tile-label">Blockiert</span></div>
</div>
<div class="agent-summary">
<button
v-for="g in childAgentSummary"
:key="g.agent"
class="agent-pill"
title="Details in der Vollansicht"
@click="router.push('/tasks/' + selectedTask.id)"
>{{ expectedFromLabel(g.agent) }} <b>{{ g.done }}/{{ g.total }}</b></button>
</div>
</template>
</section>
<!-- Seitenleiste: Eigenschaften + Aktivität -->
<aside class="detail-side v2-scroll">
<section class="side-block">
<div class="side-heading">Eigenschaften</div>
<div class="prop-row">
<span class="prop-label">Status</span>
<select v-model="detailForm.state" class="prop-control" :disabled="!canChangeState" :title="!canChangeState ? 'Nur Iris und Bao' : ''">
<option value="Backlog">Offen</option>
<option value="In progress">In Bearbeitung</option>
<option value="Review">Review</option>
<option value="Blocked">Blockiert</option>
<option value="Done">Erledigt</option>
</select>
</div>
<div class="prop-row">
<span class="prop-label">Priorität</span>
<select v-model="detailForm.priority" class="prop-control">
<option value="High">High</option>
<option value="Medium">Medium</option>
<option value="Low">Low</option>
</select>
</div>
<div class="prop-row">
<span class="prop-label">Zuständig</span>
<select v-model="detailForm.assignedTo" class="prop-control">
<option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'none'" :value="option.id">{{ option.label }}</option>
</select>
</div>
<div class="prop-row">
<span class="prop-label">Fällig</span>
<input v-model="detailForm.dueDate" type="date" class="prop-control" />
</div>
<div class="prop-row">
<span class="prop-label">Quelle</span>
<span class="prop-static">{{ selectedTask.source || '—' }}</span>
</div>
</section>
<section class="side-block side-activity">
<div class="side-heading">Aktivität <span v-if="taskActivity.length" class="side-count">{{ taskActivity.length }}</span></div>
<div v-if="detailLoading" class="detail-empty">Lade</div>
<div v-else-if="recentActivity.length" class="mini-timeline v2-scroll">
<div v-for="(entry, index) in recentActivity" :key="entry.id ?? index" class="mini-entry">
<span class="mini-dot"></span>
<div class="mini-body">
<div class="mini-msg">{{ entry.message ?? 'Aktivität' }}</div>
<div class="mini-time">{{ relativeTime(entry.createdAt ?? entry.timestamp ?? null) }}</div>
</div>
</div>
</div>
<div v-else class="detail-empty">Noch keine Aktivität.</div>
<div class="comment-box">
<input
v-model="commentText"
type="text"
placeholder="Kommentar hinzufügen…"
@keydown.enter.prevent="submitComment"
/>
<button class="icon-btn send-btn" :disabled="!commentText.trim() || commentSending" aria-label="Kommentar senden" @click="submitComment"><Send :size="14" /></button>
</div>
</section>
</aside>
</div>
<footer class="detail-footer">
<p v-if="detailError" class="detail-flash error">{{ detailError }}</p>
<p v-else-if="detailSuccess" class="detail-flash success">{{ detailSuccess }}</p>
<span class="footer-spacer"></span>
<button class="btn btn-ghost" @click="closeDetailPanel">Schließen</button>
<button class="btn btn-primary" :disabled="!canSaveDetail || (detailForm.state !== selectedTask.state && !canChangeState)" @click="saveTaskDetail">
<Save :size="13" /> {{ detailSaving ? 'Speichert…' : 'Speichern' }}
</button>
</footer>
</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: var(--tx); 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: var(--clr-iris); }
.stale-badge { background: rgba(244, 63, 94, .25); color: var(--clr-stale); }
/* 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: var(--clr-stale); 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: var(--clr-stale); 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: var(--clr-iris); }
.bao-dot { background: var(--clr-bao); }
.other-dot { background: var(--clr-other); }
.stale-dot { background: var(--clr-stale); }
.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: var(--clr-stale); }
.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: var(--clr-stale); 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: var(--clr-iris); 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: var(--a-purple); 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: var(--clr-iris); }
.assignee-bao { background: rgba(59, 130, 246, .12); color: var(--clr-bao); }
.assignee-agent { background: rgba(16, 185, 129, .12); color: var(--clr-agent); }
.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: var(--pill-progress); font-size: 11px; margin: 0; }
.modal-actions, .detail-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.btn-cancel { display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 0 13px; border: 1px solid var(--line); border-radius: 9px; background: transparent; color: var(--tx-2); font-size: 12px; font-weight: 600; 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 { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; padding: 0 13px; border: 1px solid var(--line); border-radius: 9px; background: transparent; color: var(--tx-2); font-size: 12px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: background .15s, color .15s; }
.btn-ghost:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.btn-submit { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; padding: 0 16px; border: none; border-radius: 9px; background: var(--grad); color: var(--tx); font-size: 12px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: opacity .15s, transform .15s; box-shadow: var(--glow-purple); }
.btn-submit:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
.btn-submit:not(:disabled):hover { opacity: .85; transform: translateY(-1px); }
/* ── Toolbar (Suche + Ball-Filter) ── */
.board-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.tb-search { display: flex; align-items: center; gap: 8px; flex: 0 1 320px; min-width: 200px; height: 34px; padding: 0 12px; border: 1px solid var(--line); border-radius: 10px; background: rgba(124,108,255,.05); color: var(--tx-3); transition: border-color .15s; }
.tb-search:focus-within { border-color: var(--a-mid); }
.tb-search input { flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: var(--tx); font-size: 12.5px; font-family: 'Manrope', sans-serif; }
.tb-clear { border: none; background: transparent; color: var(--tx-3); font-size: 15px; cursor: pointer; padding: 0 2px; }
.tb-clear:hover { color: var(--tx); }
.tb-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.tb-chip { height: 28px; padding: 0 12px; border-radius: 20px; border: 1px solid var(--line); background: transparent; color: var(--tx-2); font-size: 11.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: background .15s, color .15s, border-color .15s; }
.tb-chip:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.tb-chip.active { background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.06)); border-color: rgba(124,108,255,.35); color: var(--tx); }
/* ── Buttons (einheitliches System) ── */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; padding: 0 13px; border-radius: 9px; font-size: 12px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; border: 1px solid transparent; transition: background .15s, color .15s, filter .15s; }
.btn-primary { border: none; background: var(--grad); color: var(--tx); box-shadow: var(--glow-purple); }
.btn-primary:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
.btn-primary:not(:disabled):hover { filter: brightness(1.08); }
.btn-approve { border-color: rgba(61,220,151,.3); background: rgba(61,220,151,.14); color: var(--st-work); }
.btn-approve:hover { background: rgba(61,220,151,.24); }
.btn-changes { border-color: rgba(251,146,60,.3); background: rgba(251,146,60,.12); color: var(--clr-review); }
.btn-changes:hover { background: rgba(251,146,60,.22); }
.icon-btn { width: 30px; height: 30px; display: grid; place-items: center; border: none; border-radius: 8px; background: transparent; color: var(--tx-3); cursor: pointer; transition: background .15s, color .15s; }
.icon-btn:hover { background: rgba(124,108,255,.1); color: var(--tx); }
/* ── Detail Panel (Quick Peek — kompakte Zusammenfassung) ── */
.detail-panel { width: min(880px, calc(100vw - 40px)); max-height: min(84vh, 780px); display: flex; flex-direction: column; background: linear-gradient(180deg, rgba(15,13,32,.97), rgba(10,9,24,.97)); border: 1px solid rgba(124,108,255,.18); border-radius: 18px; box-shadow: 0 28px 90px rgba(0,0,0,.5); overflow: hidden; }
.detail-topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line); flex: 0 0 auto; }
.detail-crumb { display: flex; align-items: center; gap: 8px; min-width: 0; }
.crumb-root { font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--tx-3); white-space: nowrap; }
.crumb-sep { color: var(--tx-3); font-size: 11px; }
.id-chip { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--line); background: rgba(124,108,255,.06); color: var(--tx-3); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; cursor: pointer; transition: color .15s, border-color .15s; }
.id-chip:hover { color: var(--tx); border-color: var(--line-2); }
.detail-topbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
.detail-content { display: grid; grid-template-columns: minmax(0, 1fr) 292px; min-height: 0; flex: 1; }
.detail-main { padding: 18px 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
.detail-title-input { background: transparent; border: none; padding: 0; color: var(--tx); font-family: 'Space Grotesk', sans-serif; font-size: 23px; font-weight: 700; letter-spacing: -0.02em; outline: none; width: 100%; }
.detail-title-input:focus { box-shadow: none; }
.detail-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.meta-chip { display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 9px; border-radius: 20px; font-size: 10.5px; font-weight: 600; background: rgba(124,108,255,.09); border: 1px solid rgba(124,108,255,.16); color: var(--tx-2); font-family: 'Manrope', sans-serif; }
.meta-chip.dim { background: transparent; border-color: var(--line); color: var(--tx-3); font-weight: 500; }
.meta-chip.ball-bao { background: rgba(59,130,246,.14); border-color: rgba(59,130,246,.3); color: var(--clr-bao); }
.meta-chip.ball-iris { background: rgba(147,51,234,.14); border-color: rgba(147,51,234,.3); color: var(--clr-iris); }
.detail-desc { width: 100%; min-height: 96px; max-height: 220px; border-radius: 11px; border: 1px solid var(--line); background: rgba(10,9,24,.5); color: var(--tx); padding: 11px 13px; font-size: 12.5px; line-height: 1.6; outline: none; resize: vertical; box-sizing: border-box; font-family: 'Manrope', sans-serif; transition: border-color .15s; }
.detail-desc:focus { border-color: var(--a-mid); box-shadow: none; }
/* Summary-Kacheln (quadratisch) */
.tile-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.tile { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; aspect-ratio: 1.15 / 1; border: 1px solid var(--line); border-radius: 13px; background: rgba(28,24,64,.28); padding: 10px; }
.tile-num { font-family: 'Space Grotesk', sans-serif; font-size: 26px; font-weight: 700; color: var(--tx); line-height: 1; }
.tile-num small { font-size: 14px; color: var(--tx-3); font-weight: 600; }
.tile-label { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--tx-3); font-weight: 600; text-align: center; }
.tile-track { width: 70%; height: 4px; border-radius: 2px; background: var(--space-3); overflow: hidden; margin-top: 4px; }
.tile-fill { height: 100%; background: var(--grad); }
.t-active { color: var(--st-think); }
.t-open { color: var(--st-queue); }
.t-blocked { color: var(--st-block); }
.agent-summary { display: flex; flex-wrap: wrap; gap: 6px; }
.agent-pill { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 11px; border-radius: 20px; border: 1px solid var(--line); background: rgba(16,185,129,.07); color: var(--tx-2); font-size: 11px; font-family: 'Manrope', sans-serif; cursor: pointer; transition: border-color .15s, color .15s; }
.agent-pill b { color: var(--tx); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; font-weight: 600; }
.agent-pill:hover { border-color: var(--line-2); color: var(--tx); }
/* Seitenleiste: Eigenschaften + Aktivität */
.detail-side { border-left: 1px solid var(--line); background: rgba(255,255,255,.015); padding: 14px 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
.side-block { display: flex; flex-direction: column; gap: 2px; }
.side-heading { display: flex; align-items: center; gap: 7px; margin-bottom: 7px; color: var(--tx-3); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; }
.side-count { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; padding: 0 6px; border-radius: 8px; background: var(--glass-2); color: var(--tx-3); }
.prop-row { display: grid; grid-template-columns: 82px minmax(0, 1fr); align-items: center; gap: 8px; min-height: 32px; border-radius: 8px; padding: 0 6px; transition: background .15s; }
.prop-row:hover { background: rgba(124,108,255,.05); }
.prop-label { font-size: 11px; color: var(--tx-3); font-family: 'Manrope', sans-serif; }
.prop-control { width: 100%; height: 28px; padding: 0 7px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--tx); font-size: 12px; font-family: 'Manrope', sans-serif; outline: none; cursor: pointer; transition: border-color .15s, background .15s; box-sizing: border-box; }
.prop-row:hover .prop-control:not(:disabled) { border-color: var(--line); background: rgba(10,9,24,.5); }
.prop-control:focus { border-color: var(--a-mid); background: rgba(10,9,24,.6); }
.prop-control:disabled { opacity: .5; cursor: not-allowed; }
select.prop-control option { background: var(--space-2); color: var(--tx); }
.prop-static { font-size: 12px; color: var(--tx-2); padding: 0 7px; }
.side-activity { flex: 1; min-height: 0; }
.mini-timeline { display: flex; flex-direction: column; gap: 9px; overflow-y: auto; max-height: 236px; padding-right: 3px; }
.mini-entry { display: grid; grid-template-columns: 8px minmax(0, 1fr); gap: 8px; }
.mini-dot { width: 6px; height: 6px; border-radius: 50%; margin-top: 5px; background: var(--a-mid); box-shadow: 0 0 0 3px rgba(124,108,255,.12); }
.mini-msg { font-size: 11px; color: var(--tx-2); line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mini-time { font-size: 9.5px; color: var(--tx-3); margin-top: 2px; font-family: 'JetBrains Mono', monospace; }
.comment-box { display: flex; align-items: center; gap: 6px; margin-top: 10px; padding: 4px 4px 4px 11px; border: 1px solid var(--line); border-radius: 10px; background: rgba(10,9,24,.5); transition: border-color .15s; flex: 0 0 auto; }
.comment-box:focus-within { border-color: var(--a-mid); }
.comment-box input { flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: var(--tx); font-size: 12px; font-family: 'Manrope', sans-serif; }
.send-btn { width: 26px; height: 26px; border-radius: 7px; background: rgba(124,108,255,.14); color: var(--a-mid); flex: 0 0 auto; }
.send-btn:disabled { opacity: .4; cursor: not-allowed; }
.send-btn:not(:disabled):hover { background: rgba(124,108,255,.24); color: var(--tx); }
/* Footer */
.detail-footer { display: flex; align-items: center; gap: 8px; padding: 11px 16px; border-top: 1px solid var(--line); flex: 0 0 auto; }
.footer-spacer { flex: 1; }
.detail-flash.error { color: var(--st-block); font-size: 11px; margin: 0; }
.detail-flash.success { color: var(--st-work); font-size: 11px; margin: 0; }
.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-side { 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; max-height: 100vh; height: 100vh; border-radius: 0; } .detail-main { padding: 14px; } .detail-side { padding: 12px 14px; } .detail-title-input { font-size: 19px; } .tile-grid { grid-template-columns: repeat(2, 1fr); } }
@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>