+
-
-
diff --git a/frontend/src/components/ui/EmptyState.vue b/frontend/src/components/ui/EmptyState.vue
deleted file mode 100644
index 4b88b78..0000000
--- a/frontend/src/components/ui/EmptyState.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
{{ title }}
-
{{ description }}
-
-
-
-
-
-
-
diff --git a/frontend/src/components/ui/PageHeading.vue b/frontend/src/components/ui/PageHeading.vue
deleted file mode 100644
index a94a913..0000000
--- a/frontend/src/components/ui/PageHeading.vue
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
-
-
-
- {{ eyebrow }}
-
-
{{ title }}
-
{{ subtitle }}
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/ui/SectionHeader.vue b/frontend/src/components/ui/SectionHeader.vue
deleted file mode 100644
index e2006d6..0000000
--- a/frontend/src/components/ui/SectionHeader.vue
+++ /dev/null
@@ -1,116 +0,0 @@
-
-
-
-
-
-
-
diff --git a/frontend/src/components/ui/StatusDot.vue b/frontend/src/components/ui/StatusDot.vue
deleted file mode 100644
index a507a33..0000000
--- a/frontend/src/components/ui/StatusDot.vue
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
- {{ label }}
-
-
-
-
diff --git a/frontend/src/components/ui/ToastContainer.vue b/frontend/src/components/ui/ToastContainer.vue
index a7a1e3f..6e68191 100644
--- a/frontend/src/components/ui/ToastContainer.vue
+++ b/frontend/src/components/ui/ToastContainer.vue
@@ -7,18 +7,18 @@ const { toasts, remove } = useToast()
const typeConfig: Record
= {
success: {
icon: CheckCircle,
- color: 'var(--st-work)',
- bg: 'rgba(61, 220, 151, 0.10)',
+ color: '#22c55e',
+ bg: 'rgba(34, 197, 94, 0.10)',
},
error: {
icon: XCircle,
- color: 'var(--st-block)',
- bg: 'rgba(251, 113, 133, 0.10)',
+ color: '#ef4444',
+ bg: 'rgba(239, 68, 68, 0.10)',
},
info: {
icon: Info,
- color: 'var(--a-blue)',
- bg: 'rgba(79, 124, 255, 0.10)',
+ color: '#3b82f6',
+ bg: 'rgba(59, 130, 246, 0.10)',
},
}
@@ -76,7 +76,7 @@ const typeConfig: Record = {
0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent);
pointer-events: auto;
- color: var(--tx);
+ color: #e8eaf0;
font-size: 12.5px;
line-height: 1.4;
}
@@ -106,7 +106,7 @@ const typeConfig: Record = {
border: none;
border-radius: 6px;
background: transparent;
- color: var(--tx-3);
+ color: #6b7385;
cursor: pointer;
opacity: 0.5;
transition: all 0.15s;
@@ -114,7 +114,7 @@ const typeConfig: Record = {
.toast-close:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.06);
- color: var(--tx);
+ color: #e8eaf0;
}
/* Transition animations */
diff --git a/frontend/src/components/ui/button/index.ts b/frontend/src/components/ui/button/index.ts
index 9c4dd22..d54ea4e 100644
--- a/frontend/src/components/ui/button/index.ts
+++ b/frontend/src/components/ui/button/index.ts
@@ -4,35 +4,25 @@ import { type VariantProps, cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
- 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+ 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
- // shadcn-original
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
- destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
- outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
- secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
+ destructive:
+ 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
+ outline:
+ 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
+ secondary:
+ 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
-
- // Nexus V2 — Token-basierte Varianten
- /** CTA / primäre Aktion — Gradient */
- gradient: 'nexus-btn-gradient',
- /** Icon-Only — quadratischer Button ohne Text */
- icon: 'nexus-btn-icon',
- /** Ghost thin — minimal hover */
- ghostSubtle: 'nexus-btn-ghost-subtle',
- /** Danger/Blocker — rote Akzent-Aktion */
- danger: 'nexus-btn-danger',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
- iconSm: 'h-7 w-7 rounded-md',
- pill: 'h-7 px-4 rounded-full text-xs',
},
},
defaultVariants: {
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
deleted file mode 100644
index c1a787d..0000000
--- a/frontend/src/components/ui/index.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export { default as Badge } from './Badge.vue'
-export { default as Card } from './Card.vue'
-export { default as StatusDot } from './StatusDot.vue'
-export { default as PageHeading } from './PageHeading.vue'
-export { default as EmptyState } from './EmptyState.vue'
-export { default as ActivityTimeline } from './ActivityTimeline.vue'
-export { default as SectionHeader } from './SectionHeader.vue'
-export { default as Input } from './Input.vue'
-export { default as Textarea } from './Textarea.vue'
-export { default as Select } from './Select.vue'
-export { default as Dialog } from './Dialog.vue'
-export { default as ToastContainer } from './ToastContainer.vue'
-export { Button } from './button'
diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts
index b2cd388..7933ae1 100644
--- a/frontend/src/stores/tasks.ts
+++ b/frontend/src/stores/tasks.ts
@@ -321,6 +321,18 @@ export const useTaskStore = defineStore('tasks', {
}
},
+ /* ── API: Kommentar/Aktivität an Task posten ──── */
+ async postTaskActivity(id: string, message: string, type = 'comment') {
+ const res = await apiFetch(`/api/dashboard/tasks/${id}/activity`, {
+ method: 'POST',
+ body: JSON.stringify({ message, type }),
+ })
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}))
+ throw new Error(body.error || `HTTP ${res.status}`)
+ }
+ },
+
/* ── API: Fetch agent workflow overview ──────── */
async fetchAgentOverview(staleHours = 2) {
this.agentOverviewLoading = true
diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue
index 122f320..7e41db1 100644
--- a/frontend/src/views/TaskBoardView.vue
+++ b/frontend/src/views/TaskBoardView.vue
@@ -13,7 +13,7 @@
* - Waiting section for Iris overview
*/
import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
-import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText, RotateCcw } from '@lucide/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'
@@ -213,13 +213,16 @@ function toDateInputValue(date?: string | null): string {
}
function flattenBoard() {
- return [
+ 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())
@@ -233,13 +236,63 @@ const canSaveDetail = computed(() => detailForm.title.trim().length > 0 && !deta
*/
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 minutesSince(dateStr?: string | null): number {
+ if (!dateStr) return Infinity
+ return (Date.now() - new Date(dateStr).getTime()) / 60000
+}
+
+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: taskStore.board.offen, dot: 'var(--st-queue)', ring: 'rgba(251,191,36,.25)' },
- { key: 'inProgress', name: 'In Bearbeitung', tasks: taskStore.board.inProgress, dot: 'var(--st-work)', ring: 'rgba(61,220,151,.25)' },
- { key: 'review', name: 'Review', tasks: taskStore.board.review, dot: '#fb923c', ring: 'rgba(251,146,60,.25)' },
- { key: 'done', name: 'Erledigt', tasks: taskStore.board.done, dot: 'var(--st-work)', ring: 'rgba(61,220,151,.25)' },
- { key: 'blocked', name: 'Blockiert', tasks: taskStore.board.blocked, dot: 'var(--st-block)', ring: 'rgba(251,113,133,.25)' },
+ { 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: '#fb923c', 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) {
@@ -335,6 +388,7 @@ async function openQuickPeek(taskId: string) {
}
function closeDetailPanel() {
+ commentText.value = ''
showDetailPanel.value = false
selectedTaskId.value = null
childTasks.value = []
@@ -392,6 +446,55 @@ async function saveTaskDetail() {
}
}
+/* ── 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()
+ 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()
@@ -456,6 +559,23 @@ onUnmounted(() => {
+
+