Files
nexus/frontend/src/components/board/BoardCard.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

291 lines
14 KiB
Vue

<script setup lang="ts">
/**
* BoardCard — eine Master-Task-Karte im Board.
*
* Zeigt nur Top-Level-Tasks; Child-Tasks der Agenten leben ausklappbar
* IN der Karte (gruppiert nach Agent) statt als eigene Spalten-Karten —
* so bleibt das Board übersichtlich, auch wenn Iris groß zerlegt.
*
* Ball = wer gerade dran ist. Stalled = In-Bearbeitung ohne Aktivität
* seit der Schwelle (Watchdog meldet parallel an Iris).
*/
import { computed, ref } from 'vue'
import { ChevronRight, Check, RotateCcw, Bot, User, AlertTriangle } from '@lucide/vue'
import type { DashboardTaskDto } from '../../stores/tasks'
import { TASK_AGENT_LABELS } from '../../constants/agentPool'
const props = defineProps<{
task: DashboardTaskDto
column: string
canReview: boolean
stallThresholdMin: number
}>()
const emit = defineEmits<{
open: [id: string]
approve: [id: string]
requestChanges: [task: DashboardTaskDto]
dragstart: [e: DragEvent, id: string]
dragend: [e: DragEvent]
}>()
const expanded = ref(false)
const children = computed(() => props.task.childTasks ?? [])
const hasChildren = computed(() => children.value.length > 0)
const totalChildren = computed(() => props.task.childTaskCount ?? children.value.length)
const doneChildren = computed(() =>
props.task.doneChildTaskCount ?? children.value.filter(c => c.state === 'Done').length
)
const progressPct = computed(() =>
totalChildren.value > 0 ? Math.round((doneChildren.value / totalChildren.value) * 100) : 0
)
/* ── Ball: wer ist dran ───────────────────────────── */
const ballAgent = computed(() => {
const s = props.task.state.toLowerCase()
if (s === 'review') return 'bao'
if (s === 'done') return null
if (s === 'backlog') return props.task.expectedFrom || 'iris'
return props.task.expectedFrom || props.task.assignedTo || 'iris'
})
function agentLabel(id?: string | null): string {
if (!id) return '—'
return TASK_AGENT_LABELS[id.toLowerCase()] ?? id
}
function agentClass(id?: string | null): string {
const lower = (id ?? '').toLowerCase()
if (lower === 'iris') return 'is-iris'
if (lower === 'bao') return 'is-bao'
return 'is-agent'
}
/* ── Stalled-Erkennung (rein aus Aktivitätszeit) ──── */
function minutesSince(dateStr?: string | null): number {
if (!dateStr) return Infinity
return (Date.now() - new Date(dateStr).getTime()) / 60000
}
function isStalled(t: DashboardTaskDto): boolean {
if (t.state.toLowerCase() !== 'in progress') return false
return minutesSince(t.lastActivityAt ?? t.updatedAt) > props.stallThresholdMin
}
const masterStalled = computed(() => {
if (hasChildren.value) return children.value.some(isStalled)
return isStalled(props.task)
})
/* ── Child-Gruppierung nach Agent ─────────────────── */
const childrenByAgent = computed(() => {
const groups = new Map<string, DashboardTaskDto[]>()
for (const child of children.value) {
const key = child.assignedTo || 'unassigned'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(child)
}
return [...groups.entries()].map(([agent, tasks]) => ({ agent, tasks }))
})
const assigneeInitials = computed(() => {
const unique = new Set(children.value.map(c => c.assignedTo).filter(Boolean) as string[])
if (!unique.size && props.task.assignedTo) unique.add(props.task.assignedTo)
return [...unique].slice(0, 4).map(a => agentLabel(a).replace(/^[^\w]+/, '').slice(0, 2).toUpperCase())
})
function priorityLabel(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high' || lower === 'critical' || lower === 'urgent') return 'High'
if (lower === 'low' || lower === 'minor') return 'Low'
return 'Med'
}
function priorityClass(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high' || lower === 'critical' || lower === 'urgent') return 'prio-high'
if (lower === 'low' || lower === 'minor') return 'prio-low'
return 'prio-med'
}
function childStateLabel(state: string): string {
const map: Record<string, string> = {
'backlog': 'Offen', 'in progress': 'Aktiv', 'review': 'Review', 'blocked': 'Blockiert', 'done': 'Fertig',
}
return map[state.toLowerCase()] ?? state
}
function childStateClass(state: string): string {
const s = state.toLowerCase()
if (s === 'done') return 'cs-done'
if (s === 'blocked') return 'cs-blocked'
if (s === 'review') return 'cs-review'
if (s === 'in progress') return 'cs-active'
return 'cs-backlog'
}
function relTime(date?: string | null): string {
if (!date) return 'keine Aktivität'
const mins = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const h = Math.round(mins / 60)
if (h < 24) return `vor ${h} h`
return `vor ${Math.round(h / 24)} d`
}
function toggleExpand(e: MouseEvent) {
e.stopPropagation()
expanded.value = !expanded.value
}
</script>
<template>
<div
class="mcard"
:class="{ 'mcard-blocked': column === 'blocked', 'mcard-stalled': masterStalled }"
draggable="true"
@click="emit('open', task.id)"
@dragstart="emit('dragstart', $event, task.id)"
@dragend="emit('dragend', $event)"
>
<!-- Kopf: Ball + Priorität + Stalled -->
<div class="mcard-top">
<span v-if="ballAgent" class="ball" :class="agentClass(ballAgent)" :title="'Ball bei ' + agentLabel(ballAgent)">
<Bot v-if="ballAgent === 'iris'" :size="11" />
<User v-else-if="ballAgent === 'bao'" :size="11" />
<span v-else class="ball-dot"></span>
{{ agentLabel(ballAgent) }}
</span>
<span class="prio" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
<span v-if="masterStalled" class="stalled-chip" title="Keine Aktivität seit der Schwelle — Iris benachrichtigt">
<AlertTriangle :size="11" /> hängt
</span>
</div>
<!-- Titel -->
<div class="mcard-title">{{ task.title }}</div>
<!-- Fortschritt aus Children -->
<div v-if="hasChildren" class="mcard-progress">
<div class="progress-row">
<button class="expand-btn" :class="{ open: expanded }" @click="toggleExpand" :aria-label="expanded ? 'Einklappen' : 'Ausklappen'">
<ChevronRight :size="14" />
</button>
<span class="progress-text">{{ doneChildren }}/{{ totalChildren }} Teilaufgaben</span>
<div class="avatars">
<span v-for="(ini, i) in assigneeInitials" :key="i" class="avatar-mini">{{ ini }}</span>
</div>
</div>
<div class="progress-track"><div class="progress-fill" :style="{ width: progressPct + '%' }"></div></div>
</div>
<div v-else-if="task.detail" class="mcard-preview">{{ task.detail }}</div>
<!-- Ausgeklappte Children, gruppiert nach Agent -->
<div v-if="expanded && hasChildren" class="children" @click.stop>
<div v-for="group in childrenByAgent" :key="group.agent" class="child-group">
<div class="child-group-head">
<span class="child-agent" :class="agentClass(group.agent)">{{ agentLabel(group.agent) }}</span>
<span class="child-group-count">{{ group.tasks.length }}</span>
</div>
<button
v-for="child in group.tasks"
:key="child.id"
type="button"
class="child-row"
@click.stop="emit('open', child.id)"
>
<span class="child-title">{{ child.title }}</span>
<span class="child-tail">
<span v-if="isStalled(child)" class="child-stalled" title="hängt"><AlertTriangle :size="10" /></span>
<span class="child-state" :class="childStateClass(child.state)">{{ childStateLabel(child.state) }}</span>
</span>
</button>
</div>
</div>
<!-- Review-Aktionen -->
<div v-if="column === 'review' && canReview" class="review-actions" @click.stop>
<button class="rv-approve" @click="emit('approve', task.id)"><Check :size="13" /> Abnehmen</button>
<button class="rv-changes" @click="emit('requestChanges', task)"><RotateCcw :size="13" /> Änderung</button>
</div>
<div class="mcard-meta">
<span>Update {{ relTime(task.lastActivityAt ?? task.updatedAt) }}</span>
</div>
</div>
</template>
<style scoped>
.mcard {
padding: 11px 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;
text-align: left;
width: 100%;
}
.mcard:hover { transform: translateY(-1px); border-color: var(--line-2); box-shadow: 0 8px 24px -6px rgba(0,0,0,.4); }
.mcard-blocked { border-left: 3px solid var(--st-block); }
.mcard-stalled { border-left: 3px solid var(--st-queue); }
.mcard-top { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; flex-wrap: wrap; }
.ball { display: inline-flex; align-items: center; gap: 4px; font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 20px; }
.ball.is-iris { background: rgba(147, 51, 234, .16); color: var(--clr-iris); }
.ball.is-bao { background: rgba(59, 130, 246, .16); color: var(--clr-bao); }
.ball.is-agent { background: rgba(16, 185, 129, .14); color: var(--clr-agent); }
.ball-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.prio { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; }
.prio-high { color: var(--st-block); border-color: var(--st-block); }
.prio-med { color: var(--st-queue); border-color: var(--st-queue); }
.prio-low { color: var(--a-blue); border-color: var(--a-blue); }
.stalled-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; font-size: 9.5px; font-weight: 600; color: var(--st-queue); background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); padding: 1px 6px; border-radius: 20px; }
.mcard-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; }
.mcard-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; }
.mcard-progress { margin-top: 9px; }
.progress-row { display: flex; align-items: center; gap: 8px; }
.expand-btn { display: grid; place-items: center; width: 20px; height: 20px; border: none; border-radius: 6px; background: rgba(124,108,255,.08); color: var(--tx-2); cursor: pointer; transition: transform .15s, background .15s; flex: 0 0 auto; }
.expand-btn:hover { background: rgba(124,108,255,.16); color: var(--tx); }
.expand-btn.open { transform: rotate(90deg); }
.progress-text { font-size: 10.5px; color: var(--tx-2); font-family: 'Manrope', sans-serif; }
.avatars { margin-left: auto; display: flex; }
.avatar-mini { width: 20px; height: 20px; margin-left: -6px; border-radius: 50%; background: var(--grad-soft); border: 1px solid var(--space-1); display: grid; place-items: center; font-size: 8px; font-weight: 700; color: var(--tx); font-family: 'JetBrains Mono', monospace; }
.avatar-mini:first-child { margin-left: 0; }
.progress-track { height: 4px; margin-top: 6px; border-radius: 2px; background: var(--space-3); overflow: hidden; }
.progress-fill { height: 100%; border-radius: 2px; background: var(--grad); transition: width .3s; }
.children { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 9px; cursor: default; }
.child-group-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.child-agent { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em; }
.child-agent.is-iris { background: rgba(147, 51, 234, .14); color: var(--clr-iris); }
.child-agent.is-bao { background: rgba(59, 130, 246, .14); color: var(--clr-bao); }
.child-agent.is-agent { background: rgba(16, 185, 129, .12); color: var(--clr-agent); }
.child-group-count { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--tx-3); }
.child-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 7px; border: none; border-radius: 7px; background: rgba(10,9,24,.4); color: var(--tx); cursor: pointer; text-align: left; transition: background .15s; }
.child-row:hover { background: rgba(124,108,255,.08); }
.child-title { flex: 1; font-size: 11px; line-height: 1.35; word-break: break-word; }
.child-tail { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.child-stalled { color: var(--st-queue); display: inline-flex; }
.child-state { font-size: 8.5px; font-weight: 700; padding: 1px 6px; border-radius: 10px; text-transform: uppercase; letter-spacing: .03em; }
.cs-done { background: rgba(61,220,151,.14); color: var(--st-work); }
.cs-blocked { background: rgba(251,113,133,.14); color: var(--st-block); }
.cs-review { background: rgba(251, 146, 60, .14); color: var(--clr-review); }
.cs-active { background: rgba(52,214,245,.14); color: var(--st-think); }
.cs-backlog { background: var(--glass-2); color: var(--tx-3); }
.review-actions { display: flex; gap: 6px; margin-top: 10px; }
.rv-approve, .rv-changes { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 8px; border-radius: 8px; font-size: 10.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: filter .15s, background .15s; }
.rv-approve { border: none; background: rgba(61,220,151,.16); color: var(--st-work); border: 1px solid rgba(61,220,151,.3); }
.rv-approve:hover { background: rgba(61,220,151,.26); }
.rv-changes { border: 1px solid rgba(251, 146, 60, .3); background: rgba(251, 146, 60, .12); color: var(--clr-review); }
.rv-changes:hover { background: rgba(251,146,60,.22); }
.mcard-meta { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--tx-3); margin-top: 7px; font-variant-numeric: tabular-nums; }
</style>