diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index b6975ac..0be01cf 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -262,9 +262,9 @@ public class DashboardController( [HttpGet("tasks/{id:guid}")] public async Task> GetTask(Guid id, CancellationToken ct) { - var task = await taskService.GetByIdAsync(id, ct); + var task = await taskService.GetDashboardTaskByIdAsync(id, ct); if (task is null) return NotFound(new { error = "Task not found." }); - return Ok(MapToDto(task)); + return Ok(task); } [HttpGet("tasks/{id:guid}/activity")] diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index 3945268..6da7d83 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -91,7 +91,9 @@ public sealed record DashboardTaskDto( DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, bool IsAgentTask = false, - string? ExpectedFrom = null + string? ExpectedFrom = null, + string? LastActivityMessage = null, + DateTimeOffset? LastActivityAt = null ); public sealed record CreateDashboardTaskRequest( diff --git a/backend/Repositories/ActivityRepository.cs b/backend/Repositories/ActivityRepository.cs index 4860b05..77afda2 100644 --- a/backend/Repositories/ActivityRepository.cs +++ b/backend/Repositories/ActivityRepository.cs @@ -8,6 +8,18 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository public Task> GetRecentAsync(int take, CancellationToken ct = default) => db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct); + public Task> GetRecentForTasksAsync(IEnumerable taskIds, CancellationToken ct = default) + { + var ids = taskIds.Distinct().ToList(); + if (ids.Count == 0) + return Task.FromResult(new List()); + + return db.Activity.AsNoTracking() + .Where(x => x.TaskId.HasValue && ids.Contains(x.TaskId.Value)) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(ct); + } + public async Task<(List Items, int TotalCount)> GetPagedAsync( string? type, string? sort, int page, int pageSize, CancellationToken ct = default) { diff --git a/backend/Repositories/IActivityRepository.cs b/backend/Repositories/IActivityRepository.cs index 9f2b0c9..9d8650e 100644 --- a/backend/Repositories/IActivityRepository.cs +++ b/backend/Repositories/IActivityRepository.cs @@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories; public interface IActivityRepository { Task> GetRecentAsync(int take, CancellationToken ct = default); + Task> GetRecentForTasksAsync(IEnumerable taskIds, CancellationToken ct = default); Task<(List Items, int TotalCount)> GetPagedAsync( string? type, string? sort, int page, int pageSize, CancellationToken ct = default); Task> GetByAgentAsync(string agentId, int take, CancellationToken ct = default); diff --git a/backend/Services/ITaskService.cs b/backend/Services/ITaskService.cs index 1fc8097..84abacf 100644 --- a/backend/Services/ITaskService.cs +++ b/backend/Services/ITaskService.cs @@ -36,6 +36,7 @@ public interface ITaskService Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); Task> GetChildTasksAsync(Guid parentId, CancellationToken ct = default); Task> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default); + Task GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default); // Agent Workflow Overview Task> GetWaitingTasksAsync(CancellationToken ct = default); diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs index ef9ea8d..09cf67f 100644 --- a/backend/Services/TaskService.cs +++ b/backend/Services/TaskService.cs @@ -20,6 +20,15 @@ public sealed class TaskService( public async Task GetByIdAsync(Guid id, CancellationToken ct = default) => await taskRepo.GetByIdAsync(id, ct); + public async Task GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default) + { + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) return null; + + var activity = await activityRepo.GetRecentForTasksAsync([task.Id], ct); + return MapToDtoWithActivity(task, activity); + } + public async Task> GetPendingApprovalAsync(CancellationToken ct = default) => await taskRepo.GetPendingApprovalAsync(ct); @@ -162,35 +171,32 @@ public sealed class TaskService( var agentTasks = all.Where(t => t.IsAgentTask).ToList(); - var waitingForBao = agentTasks + var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct); + + List map(IEnumerable tasks) + => tasks.Select(task => MapToDtoWithActivity(task, activity)).ToList(); + + var waitingForBao = map(agentTasks .Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) && - !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) - .Select(MapToDto) - .ToList(); + !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))); - var waitingForIris = agentTasks + var waitingForIris = map(agentTasks .Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) && - !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) - .Select(MapToDto) - .ToList(); + !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))); - var waitingForOthers = agentTasks + var waitingForOthers = map(agentTasks .Where(t => { var expected = (t.ExpectedFrom ?? "").ToLowerInvariant(); return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase); - }) - .Select(MapToDto) - .ToList(); + })); - var staleTasks = agentTasks + var staleTasks = map(agentTasks .Where(t => (string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) || string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) && - t.UpdatedAt < threshold) - .Select(MapToDto) - .ToList(); + t.UpdatedAt < threshold)); return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, staleTasks, staleThreshold); @@ -533,6 +539,21 @@ public sealed class TaskService( t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, t.IsAgentTask, t.ExpectedFrom); + private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable activity) + { + var last = activity + .Where(e => e.TaskId == t.Id) + .OrderByDescending(e => e.CreatedAt) + .FirstOrDefault(); + + return new DashboardTaskDto( + t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, + t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, + t.IsAgentTask, t.ExpectedFrom, + last?.Message, + last?.CreatedAt); + } + /// /// Validates AssignedTo — only recognized agent values are accepted. /// Returns null for invalid values. diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 95e57de..53858d1 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -27,6 +27,8 @@ export interface DashboardTaskDto { updatedAt: string isAgentTask?: boolean expectedFrom?: string | null + lastActivityMessage?: string | null + lastActivityAt?: string | null } export interface BoardGroup { diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue index f343e74..5b77dad 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 } from '@lucide/vue' +import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText } from '@lucide/vue' import { useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' import { useTaskStore } from '../stores/tasks' @@ -238,6 +238,22 @@ function hoursSince(dateStr: string): number { return Math.round((now - then) / 3600000) } +function relativeTime(date?: string | null): string { + if (!date) return 'keine Updates' + const diffMs = Date.now() - new Date(date).getTime() + const mins = Math.max(0, Math.round(diffMs / 60000)) + if (mins < 1) return 'gerade eben' + if (mins < 60) return `vor ${mins} min` + const hours = Math.round(mins / 60) + if (hours < 24) return `vor ${hours} h` + const days = Math.round(hours / 24) + return `vor ${days} d` +} + +function activityHint(task: BoardTask): string { + return task.lastActivityMessage?.trim() || (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status') +} + /* ── Task Navigation ───────────────────────────── */ function navigateToTask(taskId: string) { router.push('/tasks/' + taskId) @@ -401,9 +417,12 @@ onUnmounted(() => { Warte auf Iris {{ waitingForIrisCount }}
Keine Tasks
-
- {{ t.title }} - {{ stateLabel(t.state) }} +
+
+ {{ t.title }} +
{{ activityHint(t) }}
+
+ {{ relativeTime(t.lastActivityAt ?? t.updatedAt) }}
@@ -413,9 +432,12 @@ onUnmounted(() => { Warte auf Bao {{ waitingForBaoCount }}
Keine Tasks
-
- {{ t.title }} - {{ stateLabel(t.state) }} +
+
+ {{ t.title }} +
{{ activityHint(t) }}
+
+ {{ relativeTime(t.lastActivityAt ?? t.updatedAt) }}
@@ -425,8 +447,11 @@ onUnmounted(() => { Warte auf andere {{ taskStore.waitingForOthersTasks.length }}
Keine Tasks
-
- {{ t.title }} +
+
+ {{ t.title }} +
{{ activityHint(t) }}
+
{{ expectedFromLabel(t.expectedFrom) }}
@@ -437,8 +462,11 @@ onUnmounted(() => { Stale Tasks {{ staleCount }}
Keine stale Tasks
-
- {{ t.title }} +
+
+ {{ t.title }} +
{{ activityHint(t) }}
+
{{ hoursSince(t.updatedAt) }}h offen
@@ -493,7 +521,8 @@ onUnmounted(() => {
{{ task.title }}
{{ task.detail }}
-
{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}
+
{{ activityHint(task) }}
+
Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}
Keine Aufgaben
@@ -541,7 +570,8 @@ onUnmounted(() => {
{{ task.title }}
{{ task.detail }}
-
{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}
+
{{ activityHint(task) }}
+
Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}
Keine Aufgaben
@@ -589,7 +619,8 @@ onUnmounted(() => {
{{ task.title }}
{{ task.detail }}
-
{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}
+
{{ activityHint(task) }}
+
Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}
Keine delegierten Aufgaben
@@ -637,7 +668,8 @@ onUnmounted(() => {
{{ task.title }}
{{ task.detail }}
-
{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}
+
{{ activityHint(task) }}
+
Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}
Keine Aufgaben
@@ -680,7 +712,8 @@ onUnmounted(() => {
{{ task.title }}
{{ task.detail }}
-
{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}
+
{{ activityHint(task) }}
+
Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}
Keine Aufgaben
@@ -728,7 +761,7 @@ onUnmounted(() => {
{{ task.title }}
{{ task.detail }}
-
{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}
+
Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}
Keine Blockierer
@@ -818,6 +851,10 @@ onUnmounted(() => { ⏳ Erwartet: {{ selectedTask.expectedFrom }} Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }} Erstellt {{ formatDate(selectedTask.createdAt) }} + Letzter Status {{ relativeTime(selectedTask.lastActivityAt ?? selectedTask.updatedAt) }} + +
+ Letzter Fortschritt: {{ activityHint(selectedTask) }}
@@ -1003,7 +1040,9 @@ onUnmounted(() => { .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; } -.iris-task-title { font-size: 11.5px; font-weight: 500; color: var(--tx); } +.progress-row { align-items: flex-start; } +.iris-task-title { font-size: 11.5px; font-weight: 500; color: var(--tx); display: block; } +.iris-task-progress { margin-top: 4px; font-size: 10px; color: var(--tx-3); line-height: 1.35; } .iris-task-meta { font-size: 10px; color: var(--tx-3); white-space: nowrap; } .stale-row { background: rgba(244,63,94,.05); border-radius: 4px; } .stale-meta { color: #fda4af; font-weight: 600; } @@ -1041,6 +1080,7 @@ select:disabled { opacity: .45; cursor: not-allowed; } .assignee-bao { background: rgba(59, 130, 246, .12); color: #60a5fa; } .card-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; } .card-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.card-progress-hint { margin-top: 7px; font-size: 10.5px; color: var(--tx-2); line-height: 1.4; padding: 6px 8px; border-radius: 8px; background: rgba(124,108,255,.07); border: 1px solid rgba(124,108,255,.12); } .card-meta { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--tx-3); margin-top: 5px; font-variant-numeric: tabular-nums; } .empty-col, .detail-empty { display: flex; align-items: center; justify-content: center; padding: 24px 12px; font-size: 11px; color: var(--tx-3); font-style: italic; font-family: 'Manrope', sans-serif; } @@ -1092,6 +1132,7 @@ select:disabled { opacity: .45; cursor: not-allowed; } .detail-meta-row span { display: inline-flex; align-items: center; gap: 5px; } .meta-agent-tag { color: #c084fc; } .meta-expected { color: #a78bfa; } +.detail-progress-banner { padding: 10px 12px; border-radius: 12px; background: rgba(124,108,255,.08); border: 1px solid rgba(124,108,255,.14); color: var(--tx-2); font-size: 12px; } .detail-section { background: rgba(255,255,255,.02); border: 1px solid var(--line); border-radius: 16px; padding: 16px; } .detail-section-header, .sidebar-heading { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--tx-2); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } .detail-textarea { width: 100%; min-height: 180px; border-radius: 12px; border: 1px solid var(--line); background: rgba(10,9,24,.55); color: var(--tx); padding: 14px; font-size: 14px; line-height: 1.6; outline: none; box-sizing: border-box; } diff --git a/frontend/src/views/TaskDetailView.vue b/frontend/src/views/TaskDetailView.vue index 64933be..ec29bac 100644 --- a/frontend/src/views/TaskDetailView.vue +++ b/frontend/src/views/TaskDetailView.vue @@ -12,9 +12,10 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ArrowLeft, Clock3, CalendarDays, ListChecks, Save, Plus, - X, Link2, MessageSquare, Trash2, CheckCircle, AlertCircle, + X, Link2, MessageSquare, Trash2, CheckCircle, AlertCircle, ShieldBan, } from '@lucide/vue' import { apiFetch } from '../services/api' +import { useAuthStore } from '../stores/auth' /* ── Types ──────────────────────────────────── */ interface TaskDto { @@ -29,6 +30,10 @@ interface TaskDto { dueDate: string | null createdAt: string updatedAt: string + isAgentTask?: boolean + expectedFrom?: string | null + lastActivityMessage?: string | null + lastActivityAt?: string | null } interface ActivityEntry { @@ -42,6 +47,7 @@ interface ActivityEntry { /* ── State ───────────────────────────────────── */ const route = useRoute() const router = useRouter() +const authStore = useAuthStore() const taskId = computed(() => route.params.id as string) const task = ref(null) @@ -79,6 +85,7 @@ const postingComment = ref(false) /* ── Delete state ───────────────────────────── */ const deletingTask = ref('') // id of subtask being deleted +const canChangeState = computed(() => authStore.isIris || authStore.isBao) /* ── Helpers ────────────────────────────────── */ function statusLabel(state: string): string { @@ -129,6 +136,22 @@ function toDateInput(date?: string | null): string { return new Date(date).toISOString().slice(0, 10) } +function relativeTime(date?: string | null): string { + if (!date) return 'keine Updates' + const diffMs = Date.now() - new Date(date).getTime() + const mins = Math.max(0, Math.round(diffMs / 60000)) + if (mins < 1) return 'gerade eben' + if (mins < 60) return `vor ${mins} min` + const hours = Math.round(mins / 60) + if (hours < 24) return `vor ${hours} h` + const days = Math.round(hours / 24) + return `vor ${days} d` +} + +function progressHint(taskLike: Pick): string { + return taskLike.lastActivityMessage?.trim() || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status') +} + /* ── API calls ───────────────────────────────── */ async function loadTask() { loading.value = true @@ -188,12 +211,16 @@ async function saveTask() { if (!res.ok) throw new Error(`HTTP ${res.status}`) // Also update state if changed if (form.state !== task.value.state) { - const stateRes = await apiFetch(`/api/dashboard/tasks/${taskId.value}/status`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: form.state }), - }) - if (!stateRes.ok) throw new Error('Status-Update fehlgeschlagen') + if (!canChangeState.value) { + form.state = task.value.state + } else { + const stateRes = await apiFetch(`/api/dashboard/tasks/${taskId.value}/status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: form.state }), + }) + if (!stateRes.ok) throw new Error('Status-Update fehlgeschlagen') + } } saveSuccess.value = true await loadTask() @@ -236,6 +263,7 @@ async function createSubtask() { } async function updateSubtaskState(subtaskId: string, newState: string) { + if (!canChangeState.value) return try { await apiFetch(`/api/dashboard/tasks/${subtaskId}/status`, { method: 'PATCH', @@ -332,9 +360,16 @@ function handleKeydown(e: KeyboardEvent) { {{ statusLabel(task.state) }} Quelle: {{ task.source }} {{ task.priority }} Priorität + 🤖 Agent-Task + ⏳ Erwartet: {{ task.expectedFrom }} +
+ + Inhalte sind editierbar, Statuswechsel bleiben Bao/Iris vorbehalten. +
+
@@ -349,6 +384,14 @@ function handleKeydown(e: KeyboardEvent) { Aktualisiert {{ formatDate(task.updatedAt, true) }} + + + Letzter Status {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }} + +
+ +
+ Letzter Fortschritt: {{ progressHint(task) }}
@@ -407,6 +450,7 @@ function handleKeydown(e: KeyboardEvent) { +