b82d88563a
- 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)
1147 lines
61 KiB
Vue
1147 lines
61 KiB
Vue
<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 = ''">×</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 > 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">×</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">×</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">×</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>
|