feat: ship agent progress visibility
This commit is contained in:
@@ -262,9 +262,9 @@ public class DashboardController(
|
||||
[HttpGet("tasks/{id:guid}")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> 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")]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -8,6 +8,18 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
|
||||
|
||||
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = taskIds.Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
return Task.FromResult(new List<ActivityEvent>());
|
||||
|
||||
return db.Activity.AsNoTracking()
|
||||
.Where(x => x.TaskId.HasValue && ids.Contains(x.TaskId.Value))
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
|
||||
public interface IActivityRepository
|
||||
{
|
||||
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
|
||||
Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default);
|
||||
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
|
||||
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
|
||||
|
||||
@@ -36,6 +36,7 @@ public interface ITaskService
|
||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
||||
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
||||
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
// Agent Workflow Overview
|
||||
Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -20,6 +20,15 @@ public sealed class TaskService(
|
||||
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> await taskRepo.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<DashboardTaskDto?> 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<IReadOnlyList<WorkTask>> 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<DashboardTaskDto> map(IEnumerable<WorkTask> 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<ActivityEvent> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates AssignedTo — only recognized agent values are accepted.
|
||||
/// Returns null for invalid values.
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface DashboardTaskDto {
|
||||
updatedAt: string
|
||||
isAgentTask?: boolean
|
||||
expectedFrom?: string | null
|
||||
lastActivityMessage?: string | null
|
||||
lastActivityAt?: string | null
|
||||
}
|
||||
|
||||
export interface BoardGroup {
|
||||
|
||||
@@ -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 <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">
|
||||
<span class="iris-task-title">{{ t.title }}</span>
|
||||
<span class="iris-task-meta">{{ stateLabel(t.state) }}</span>
|
||||
<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>
|
||||
|
||||
@@ -413,9 +432,12 @@ onUnmounted(() => {
|
||||
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">
|
||||
<span class="iris-task-title">{{ t.title }}</span>
|
||||
<span class="iris-task-meta">{{ stateLabel(t.state) }}</span>
|
||||
<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>
|
||||
|
||||
@@ -425,8 +447,11 @@ onUnmounted(() => {
|
||||
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">
|
||||
<span class="iris-task-title">{{ t.title }}</span>
|
||||
<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>
|
||||
@@ -437,8 +462,11 @@ onUnmounted(() => {
|
||||
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">
|
||||
<span class="iris-task-title">{{ t.title }}</span>
|
||||
<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>
|
||||
@@ -493,7 +521,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
|
||||
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.offen.length" class="empty-col">Keine Aufgaben</div>
|
||||
</div>
|
||||
@@ -541,7 +570,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
|
||||
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.inProgress.length" class="empty-col">Keine Aufgaben</div>
|
||||
</div>
|
||||
@@ -589,7 +619,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
|
||||
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.delegated.length" class="empty-col">Keine delegierten Aufgaben</div>
|
||||
</div>
|
||||
@@ -637,7 +668,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
|
||||
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.review.length" class="empty-col">Keine Aufgaben</div>
|
||||
</div>
|
||||
@@ -680,7 +712,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
|
||||
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.done.length" class="empty-col">Keine Aufgaben</div>
|
||||
</div>
|
||||
@@ -728,7 +761,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.blocked.length" class="empty-col">Keine Blockierer</div>
|
||||
</div>
|
||||
@@ -818,6 +851,10 @@ onUnmounted(() => {
|
||||
<span v-if="selectedTask.expectedFrom" class="meta-expected">⏳ Erwartet: {{ selectedTask.expectedFrom }}</span>
|
||||
<span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span>
|
||||
<span><CalendarDays :size="13" /> Erstellt {{ formatDate(selectedTask.createdAt) }}</span>
|
||||
<span v-if="selectedTask.isAgentTask"><MessageSquareText :size="13" /> Letzter Status {{ relativeTime(selectedTask.lastActivityAt ?? selectedTask.updatedAt) }}</span>
|
||||
</div>
|
||||
<div v-if="selectedTask.isAgentTask" class="detail-progress-banner">
|
||||
<strong>Letzter Fortschritt:</strong> {{ activityHint(selectedTask) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<TaskDto | null>(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<TaskDto, 'lastActivityMessage' | 'expectedFrom'>): 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) {
|
||||
<span class="state-badge" :class="statusClass(task.state)">{{ statusLabel(task.state) }}</span>
|
||||
<span class="meta-chip" v-if="task.source">Quelle: {{ task.source }}</span>
|
||||
<span class="meta-chip">{{ task.priority }} Priorität</span>
|
||||
<span v-if="task.isAgentTask" class="meta-chip">🤖 Agent-Task</span>
|
||||
<span v-if="task.expectedFrom" class="meta-chip">⏳ Erwartet: {{ task.expectedFrom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!canChangeState" class="readonly-banner">
|
||||
<ShieldBan :size="14" />
|
||||
Inhalte sind editierbar, Statuswechsel bleiben Bao/Iris vorbehalten.
|
||||
</div>
|
||||
|
||||
<div class="detail-body">
|
||||
<!-- Main Column -->
|
||||
<div class="detail-main">
|
||||
@@ -349,6 +384,14 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
<CalendarDays :size="13" />
|
||||
Aktualisiert {{ formatDate(task.updatedAt, true) }}
|
||||
</span>
|
||||
<span v-if="task.isAgentTask">
|
||||
<MessageSquare :size="13" />
|
||||
Letzter Status {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="task.isAgentTask" class="progress-banner">
|
||||
<strong>Letzter Fortschritt:</strong> {{ progressHint(task) }}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@@ -407,6 +450,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
<select
|
||||
:value="child.state"
|
||||
class="state-select-mini"
|
||||
:disabled="!canChangeState"
|
||||
@change="updateSubtaskState(child.id, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="Backlog">Offen</option>
|
||||
@@ -473,7 +517,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
<div class="sidebar-head">Eigenschaften</div>
|
||||
<label class="sidebar-field">
|
||||
<span>Status</span>
|
||||
<select v-model="form.state" class="galaxy-input galaxy-select">
|
||||
<select v-model="form.state" class="galaxy-input galaxy-select" :disabled="!canChangeState">
|
||||
<option value="Backlog">Offen</option>
|
||||
<option value="In progress">In Bearbeitung</option>
|
||||
<option value="Delegated">Delegiert</option>
|
||||
@@ -514,6 +558,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
<div><dt>Quelle</dt><dd>{{ task.source || '—' }}</dd></div>
|
||||
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
|
||||
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
|
||||
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -647,6 +692,19 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
border: 1px solid rgba(150, 140, 255, 0.08);
|
||||
}
|
||||
|
||||
.readonly-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(147,51,234,.08);
|
||||
border: 1px solid rgba(147,51,234,.18);
|
||||
color: #c084fc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Detail Body Grid ────────────────────────── */
|
||||
.detail-body {
|
||||
display: grid;
|
||||
@@ -694,6 +752,15 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.progress-banner {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
border: 1px solid rgba(124, 108, 255, 0.14);
|
||||
color: #a8a3d6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Sections ──────────────────────────── */
|
||||
.section-card {
|
||||
border: 1px solid rgba(150, 140, 255, 0.10);
|
||||
@@ -830,6 +897,11 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
color: #ece9ff;
|
||||
}
|
||||
|
||||
.state-select-mini:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-mini {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1025,6 +1097,11 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
color: #ece9ff;
|
||||
}
|
||||
|
||||
.galaxy-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user