feat(board): master-task board, non-destructive stall watchdog, review flow
Board is now a clean master-task view:
- GetBoardAsync returns only top-level (master) tasks; child-tasks render
nested inside their parent card instead of as separate column cards, so a
big task split into many sub-tasks stays one card (orphans treated as master)
- New DoneChildTaskCount on the DTO for real progress bars
- Child/detail consumers (GetChildren endpoint, TaskBridgeService) query
children directly instead of scraping the flat board
Stall watchdog (replaces destructive auto-reset):
- StaleTaskRecoveryService.FlagStalledInProgressTasksAsync marks In-progress
tasks with no activity past the threshold as stalled (activity event +
Iris notification) WITHOUT resetting the column — no work is discarded.
Idempotent: a task is not re-flagged until real progress happens
- BackgroundService now runs this watchdog (TaskRecovery:StalledMinutes=40,
interval 10m); hard reset kept only on the explicit manual endpoint
Review flow (Bao/Iris only):
- POST tasks/{id}/approve (Review -> Done)
- POST tasks/{id}/request-changes (Review -> target, mandatory comment,
ExpectedFrom=iris, notifies Iris)
Frontend:
- BoardCard component: master card with ball chip (who has it), progress from
children, expand to show children grouped by agent with per-child state +
stalled marker, stalled chip on the master, review action buttons
- Request-changes modal; tasks store approveReview/requestChanges actions
Tests: watchdog flag/idempotency + review threshold; 135 backend tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -131,7 +131,8 @@ public sealed class StaleTaskRecoveryTests
|
|||||||
var recoveryService = new StaleTaskRecoveryService(
|
var recoveryService = new StaleTaskRecoveryService(
|
||||||
taskRepository,
|
taskRepository,
|
||||||
activityRepository,
|
activityRepository,
|
||||||
liveUpdateService);
|
liveUpdateService,
|
||||||
|
new FakeNotificationService());
|
||||||
|
|
||||||
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
|
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
|
||||||
|
|
||||||
@@ -143,7 +144,51 @@ public sealed class StaleTaskRecoveryTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService()
|
public async Task FlagStalledInProgressTasksAsync_FlagsStalledTask_NotifiesIris_WithoutResetting()
|
||||||
|
{
|
||||||
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||||
|
var stalledTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
|
||||||
|
|
||||||
|
var stalled = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||||
|
{
|
||||||
|
Title = "Stalled agent task",
|
||||||
|
State = "In progress",
|
||||||
|
Source = "iris",
|
||||||
|
UpdatedAt = stalledTimestamp,
|
||||||
|
CreatedAt = stalledTimestamp
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var fresh = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||||
|
{
|
||||||
|
Title = "Fresh in progress",
|
||||||
|
State = "In progress",
|
||||||
|
Source = "iris",
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||||
|
CreatedAt = stalledTimestamp
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var flagged = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
|
||||||
|
TimeSpan.FromMinutes(40), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, flagged);
|
||||||
|
// Nicht-destruktiv: bleibt In progress, kein Reset auf Backlog.
|
||||||
|
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(stalled.Id, CancellationToken.None))!.State);
|
||||||
|
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(fresh.Id, CancellationToken.None))!.State);
|
||||||
|
|
||||||
|
var activity = await fixture.TaskService.GetTaskActivityAsync(stalled.Id, CancellationToken.None);
|
||||||
|
Assert.Contains(activity, entry => string.Equals(entry.Type, "stalled", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var irisNotifications = await fixture.NotificationService.GetForUserAsync("iris", 50, false, CancellationToken.None);
|
||||||
|
Assert.Contains(irisNotifications, n => n.Type == "task_stalled" && n.TaskId == stalled.Id);
|
||||||
|
|
||||||
|
// Idempotent: erneuter Lauf meldet denselben Hänger nicht nochmal.
|
||||||
|
var flaggedAgain = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
|
||||||
|
TimeSpan.FromMinutes(40), CancellationToken.None);
|
||||||
|
Assert.Equal(0, flaggedAgain);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BackgroundService_RunWatchdogOnceAsync_UsesStalledThreshold_AndFlags()
|
||||||
{
|
{
|
||||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
@@ -154,20 +199,21 @@ public sealed class StaleTaskRecoveryTests
|
|||||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||||
{
|
{
|
||||||
StaleHours = 4,
|
StalledMinutes = 45,
|
||||||
IntervalMinutes = 30
|
IntervalMinutes = 10
|
||||||
}),
|
}),
|
||||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||||
|
|
||||||
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None);
|
var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(1, fakeRecoveryService.CallCount);
|
Assert.Equal(1, fakeRecoveryService.FlagCallCount);
|
||||||
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold);
|
Assert.Equal(0, fakeRecoveryService.ResetCallCount);
|
||||||
Assert.Equal(7, resetCount);
|
Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold);
|
||||||
|
Assert.Equal(7, flaggedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval()
|
public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval()
|
||||||
{
|
{
|
||||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||||
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
@@ -181,8 +227,8 @@ public sealed class StaleTaskRecoveryTests
|
|||||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||||
{
|
{
|
||||||
StaleHours = 2,
|
StalledMinutes = 40,
|
||||||
IntervalMinutes = 30
|
IntervalMinutes = 10
|
||||||
}),
|
}),
|
||||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||||
|
|
||||||
@@ -191,8 +237,8 @@ public sealed class StaleTaskRecoveryTests
|
|||||||
await firstCall.Task.WaitAsync(cts.Token);
|
await firstCall.Task.WaitAsync(cts.Token);
|
||||||
await backgroundService.StopAsync(CancellationToken.None);
|
await backgroundService.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(fakeRecoveryService.CallCount >= 1);
|
Assert.True(fakeRecoveryService.FlagCallCount >= 1);
|
||||||
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold);
|
Assert.Equal(TimeSpan.FromMinutes(40), fakeRecoveryService.LastThreshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -325,19 +371,52 @@ file sealed class FakeLiveUpdateService : ILiveUpdateService
|
|||||||
|
|
||||||
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
|
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
|
||||||
{
|
{
|
||||||
public int CallCount { get; private set; }
|
public int FlagCallCount { get; private set; }
|
||||||
|
public int ResetCallCount { get; private set; }
|
||||||
public TimeSpan LastThreshold { get; private set; }
|
public TimeSpan LastThreshold { get; private set; }
|
||||||
public Action? OnCall { get; set; }
|
public Action? OnCall { get; set; }
|
||||||
|
|
||||||
|
public Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
FlagCallCount++;
|
||||||
|
LastThreshold = stalledThreshold;
|
||||||
|
OnCall?.Invoke();
|
||||||
|
return Task.FromResult(7);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
CallCount++;
|
ResetCallCount++;
|
||||||
LastThreshold = staleThreshold;
|
LastThreshold = staleThreshold;
|
||||||
OnCall?.Invoke();
|
OnCall?.Invoke();
|
||||||
return Task.FromResult(7);
|
return Task.FromResult(7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
file sealed class FakeNotificationService : INotificationService
|
||||||
|
{
|
||||||
|
public List<Notification> Created { get; } = [];
|
||||||
|
|
||||||
|
public Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var notification = new Notification { Type = type, Title = title, Message = message, ForUser = forUser, TaskId = taskId };
|
||||||
|
Created.Add(notification);
|
||||||
|
return Task.FromResult(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<Notification>>(Created.Where(n => n.ForUser == forUser).ToList());
|
||||||
|
|
||||||
|
public Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default) => Task.FromResult(true);
|
||||||
|
|
||||||
|
public Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
|
||||||
|
public Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
|
||||||
|
public Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new NotificationSnapshotDto([], 0, forUser));
|
||||||
|
}
|
||||||
|
|
||||||
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
|
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
|
||||||
{
|
{
|
||||||
public T CurrentValue { get; private set; } = currentValue;
|
public T CurrentValue { get; private set; } = currentValue;
|
||||||
|
|||||||
@@ -440,7 +440,8 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
|
|||||||
var staleTaskRecoveryService = new StaleTaskRecoveryService(
|
var staleTaskRecoveryService = new StaleTaskRecoveryService(
|
||||||
taskRepository,
|
taskRepository,
|
||||||
activityRepository,
|
activityRepository,
|
||||||
liveUpdateService);
|
liveUpdateService,
|
||||||
|
notificationService);
|
||||||
|
|
||||||
var taskService = new TaskService(
|
var taskService = new TaskService(
|
||||||
taskRepository,
|
taskRepository,
|
||||||
|
|||||||
@@ -322,6 +322,52 @@ public class DashboardController(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Review-Aktionen (Bao/Iris) ──
|
||||||
|
|
||||||
|
/// <summary>Review abnehmen: Review → Done. Nur Bao/Iris.</summary>
|
||||||
|
[HttpPost("tasks/{id:guid}/approve")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> ApproveReview(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (currentTask is null)
|
||||||
|
return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
|
||||||
|
return StatusCode(403, new { error = "Review-Abnahme ist nur Iris und Bao vorbehalten." });
|
||||||
|
|
||||||
|
var result = await taskService.ApproveReviewAsync(id, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
|
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können abgenommen werden." }),
|
||||||
|
_ => Ok(MapToDto(result.Task!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Änderung anfordern: Review → Zielspalte mit Pflichtkommentar. Nur Bao/Iris.</summary>
|
||||||
|
[HttpPost("tasks/{id:guid}/request-changes")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> RequestChanges(
|
||||||
|
Guid id, [FromBody] RequestChangesRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||||
|
return BadRequest(new { error = "Ein Kommentar ist erforderlich, damit Iris weiß, was zu ändern ist." });
|
||||||
|
|
||||||
|
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (currentTask is null)
|
||||||
|
return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
|
||||||
|
return StatusCode(403, new { error = "Review-Entscheidungen sind nur Iris und Bao vorbehalten." });
|
||||||
|
|
||||||
|
var result = await taskService.RequestChangesAsync(id, request.Comment, request.TargetState, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
|
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können zurückgegeben werden." }),
|
||||||
|
_ => Ok(MapToDto(result.Task!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
|
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
|
||||||
/// Falls back to empty string (which authorization helpers reject accordingly).
|
/// Falls back to empty string (which authorization helpers reject accordingly).
|
||||||
@@ -353,19 +399,7 @@ public class DashboardController(
|
|||||||
|
|
||||||
[HttpGet("tasks/{id:guid}/children")]
|
[HttpGet("tasks/{id:guid}/children")]
|
||||||
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
|
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
|
||||||
{
|
=> Ok(await taskService.GetChildTaskDtosAsync(id, ct));
|
||||||
var board = await taskService.GetBoardAsync(ct);
|
|
||||||
var children = board.Offen
|
|
||||||
.Concat(board.InProgress)
|
|
||||||
.Concat(board.Review)
|
|
||||||
.Concat(board.Blocked)
|
|
||||||
.Concat(board.Done)
|
|
||||||
.Where(task => task.ParentTaskId == id)
|
|
||||||
.OrderByDescending(task => task.UpdatedAt)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Ok(children);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("tasks/{id:guid}")]
|
[HttpGet("tasks/{id:guid}")]
|
||||||
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
|
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ public sealed record DashboardTaskDto(
|
|||||||
List<DashboardTaskDto>? ChildTasks = null,
|
List<DashboardTaskDto>? ChildTasks = null,
|
||||||
int ChildTaskCount = 0,
|
int ChildTaskCount = 0,
|
||||||
int OpenChildTaskCount = 0,
|
int OpenChildTaskCount = 0,
|
||||||
bool HasVisibleDelegation = false
|
bool HasVisibleDelegation = false,
|
||||||
|
int DoneChildTaskCount = 0
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record CreateDashboardTaskRequest(
|
public sealed record CreateDashboardTaskRequest(
|
||||||
@@ -183,6 +184,11 @@ public sealed record PostActivityRequest(
|
|||||||
string? Type = null
|
string? Type = null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public sealed record RequestChangesRequest(
|
||||||
|
string Comment,
|
||||||
|
string? TargetState = null
|
||||||
|
);
|
||||||
|
|
||||||
// ── Agent Workflow DTOs ──
|
// ── Agent Workflow DTOs ──
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -2,5 +2,9 @@ namespace Nexus.Api.Services;
|
|||||||
|
|
||||||
public interface IStaleTaskRecoveryService
|
public interface IStaleTaskRecoveryService
|
||||||
{
|
{
|
||||||
|
/// <summary>Nicht-destruktiv: markiert hängende In-progress-Tasks und benachrichtigt Iris.</summary>
|
||||||
|
Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Destruktiv (nur manuell): setzt hängende In-progress-Tasks hart auf Backlog.</summary>
|
||||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ public interface ITaskService
|
|||||||
// Task Board
|
// Task Board
|
||||||
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||||
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
|
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default);
|
||||||
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
|
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
|
||||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
||||||
|
Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default);
|
||||||
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
||||||
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
|
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ public sealed class StaleTaskRecoveryBackgroundService(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var resetCount = await RunRecoveryOnceAsync(stoppingToken);
|
var flaggedCount = await RunWatchdogOnceAsync(stoppingToken);
|
||||||
if (resetCount > 0)
|
if (flaggedCount > 0)
|
||||||
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount);
|
logger.LogInformation("Stall watchdog flagged {FlaggedCount} stalled task(s) for Iris.", flaggedCount);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -37,10 +37,10 @@ public sealed class StaleTaskRecoveryBackgroundService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> RunRecoveryOnceAsync(CancellationToken ct = default)
|
public async Task<int> RunWatchdogOnceAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
|
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
|
||||||
return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct);
|
return await recoveryService.FlagStalledInProgressTasksAsync(optionsMonitor.CurrentValue.GetStalledThreshold(), ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ public sealed class StaleTaskRecoveryOptions
|
|||||||
{
|
{
|
||||||
public const string SectionName = "TaskRecovery";
|
public const string SectionName = "TaskRecovery";
|
||||||
|
|
||||||
|
/// <summary>Schwelle (Minuten) ohne Aktivität, ab der ein In-progress-Task als hängend gilt.</summary>
|
||||||
|
public int StalledMinutes { get; set; } = 40;
|
||||||
|
|
||||||
|
/// <summary>Prüfintervall des Watchdogs.</summary>
|
||||||
|
public int IntervalMinutes { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>Nur für den manuellen Hard-Reset-Endpoint: Alter (Stunden) ab dem hart zurückgesetzt wird.</summary>
|
||||||
public int StaleHours { get; set; } = 2;
|
public int StaleHours { get; set; } = 2;
|
||||||
public int IntervalMinutes { get; set; } = 30;
|
|
||||||
|
public TimeSpan GetStalledThreshold() => TimeSpan.FromMinutes(Math.Max(1, StalledMinutes));
|
||||||
|
|
||||||
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
|
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,81 @@ namespace Nexus.Api.Services;
|
|||||||
public sealed class StaleTaskRecoveryService(
|
public sealed class StaleTaskRecoveryService(
|
||||||
ITaskRepository taskRepository,
|
ITaskRepository taskRepository,
|
||||||
IActivityRepository activityRepository,
|
IActivityRepository activityRepository,
|
||||||
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService
|
ILiveUpdateService liveUpdateService,
|
||||||
|
INotificationService notificationService) : IStaleTaskRecoveryService
|
||||||
{
|
{
|
||||||
|
private const string StalledActivityType = "stalled";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NICHT-destruktiver Watchdog: markiert „In progress"-Tasks ohne Aktivität seit
|
||||||
|
/// <paramref name="stalledThreshold"/> als hängend (Activity-Event + Notification an Iris),
|
||||||
|
/// OHNE die Spalte zu ändern oder Arbeit zu verwerfen. Iris eskaliert dann (nachfragen,
|
||||||
|
/// neu delegieren, ggf. auf Blocked setzen). Dedup: bereits gemeldete Hänger werden nicht
|
||||||
|
/// erneut gemeldet, solange kein neuer Fortschritt (andere Activity) dazwischen liegt.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var threshold = now - stalledThreshold;
|
||||||
|
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||||
|
|
||||||
|
var inProgress = allTasks
|
||||||
|
.Where(t => string.Equals(t.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
if (inProgress.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var activities = await activityRepository.GetRecentForTasksAsync(inProgress.Select(t => t.Id), ct);
|
||||||
|
var activityByTask = activities
|
||||||
|
.Where(a => a.TaskId.HasValue)
|
||||||
|
.GroupBy(a => a.TaskId!.Value)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.CreatedAt).ToList());
|
||||||
|
|
||||||
|
var flaggedCount = 0;
|
||||||
|
|
||||||
|
foreach (var task in inProgress)
|
||||||
|
{
|
||||||
|
activityByTask.TryGetValue(task.Id, out var taskActivity);
|
||||||
|
var latest = taskActivity?.FirstOrDefault();
|
||||||
|
var lastProgressAt = latest?.CreatedAt ?? task.UpdatedAt;
|
||||||
|
|
||||||
|
if (lastProgressAt >= threshold)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Dedup: schon als hängend gemeldet und seither kein neuer Fortschritt.
|
||||||
|
if (latest is not null && string.Equals(latest.Type, StalledActivityType, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var silentFor = now - lastProgressAt;
|
||||||
|
await activityRepository.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = StalledActivityType,
|
||||||
|
Message = $"Watchdog: keine Aktivität seit {FormatDuration(silentFor)} (Schwelle {FormatDuration(stalledThreshold)}). Task bleibt In progress, Iris zur Eskalation benachrichtigt.",
|
||||||
|
TaskId = task.Id
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_stalled",
|
||||||
|
$"Task hängt: {task.Title}",
|
||||||
|
$"Seit {FormatDuration(silentFor)} keine Aktivität. Bitte nachfassen, neu delegieren oder blockieren.",
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
flaggedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flaggedCount > 0)
|
||||||
|
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
|
||||||
|
|
||||||
|
return flaggedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Destruktiver Fallback (nur manuell via Endpoint / expliziter Cron): setzt hängende
|
||||||
|
/// „In progress"-Tasks hart auf Backlog zurück. Verwirft laufenden Kontext — daher NICHT
|
||||||
|
/// mehr der Standard-Watchdog, sondern nur noch auf Anforderung.
|
||||||
|
/// </summary>
|
||||||
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||||
@@ -80,88 +153,8 @@ public sealed class StaleTaskRecoveryService(
|
|||||||
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
|
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var allTasks = await taskRepository.GetAllAsync(ct);
|
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||||
var taskIds = allTasks.Select(task => task.Id).ToList();
|
var activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
|
||||||
var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
|
return TaskService.BuildMasterBoard(allTasks, activity);
|
||||||
|
|
||||||
var backlog = new List<DashboardTaskDto>();
|
|
||||||
var inProgress = new List<DashboardTaskDto>();
|
|
||||||
var review = new List<DashboardTaskDto>();
|
|
||||||
var blocked = new List<DashboardTaskDto>();
|
|
||||||
var done = new List<DashboardTaskDto>();
|
|
||||||
|
|
||||||
foreach (var task in allTasks)
|
|
||||||
{
|
|
||||||
var dto = MapToDtoWithChildren(task, allTasks, activity);
|
|
||||||
switch (task.State.ToLowerInvariant())
|
|
||||||
{
|
|
||||||
case "backlog": backlog.Add(dto); break;
|
|
||||||
case "in progress": inProgress.Add(dto); break;
|
|
||||||
case "review": review.Add(dto); break;
|
|
||||||
case "blocked": blocked.Add(dto); break;
|
|
||||||
case "done": done.Add(dto); break;
|
|
||||||
default: backlog.Add(dto); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
backlog.Sort(SortByPriorityThenCreatedAt);
|
|
||||||
inProgress.Sort(SortByPriorityThenCreatedAt);
|
|
||||||
review.Sort(SortByPriorityThenCreatedAt);
|
|
||||||
blocked.Sort(SortByPriorityThenCreatedAt);
|
|
||||||
done.Sort(SortByPriorityThenCreatedAt);
|
|
||||||
|
|
||||||
return new BoardResponse(backlog, inProgress, review, blocked, done);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DashboardTaskDto MapToDtoWithChildren(
|
|
||||||
WorkTask task,
|
|
||||||
IReadOnlyList<WorkTask> allTasks,
|
|
||||||
IEnumerable<ActivityEvent> activity)
|
|
||||||
{
|
|
||||||
var childTasks = allTasks
|
|
||||||
.Where(candidate => candidate.ParentTaskId == task.Id)
|
|
||||||
.OrderByDescending(candidate => candidate.UpdatedAt)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity)).ToList();
|
|
||||||
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
|
|
||||||
var dto = MapToDtoWithActivity(task, activity);
|
|
||||||
|
|
||||||
return dto with
|
|
||||||
{
|
|
||||||
ChildTasks = childDtos,
|
|
||||||
ChildTaskCount = childDtos.Count,
|
|
||||||
OpenChildTaskCount = openChildTaskCount,
|
|
||||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DashboardTaskDto MapToDtoWithActivity(WorkTask task, IEnumerable<ActivityEvent> activity)
|
|
||||||
{
|
|
||||||
var last = activity
|
|
||||||
.Where(entry => entry.TaskId == task.Id)
|
|
||||||
.OrderByDescending(entry => entry.CreatedAt)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
return new DashboardTaskDto(
|
|
||||||
task.Id,
|
|
||||||
task.Title,
|
|
||||||
task.Detail,
|
|
||||||
task.Source,
|
|
||||||
task.State,
|
|
||||||
task.Priority,
|
|
||||||
task.AssignedTo,
|
|
||||||
task.ParentTaskId,
|
|
||||||
task.DueDate,
|
|
||||||
task.CreatedAt,
|
|
||||||
task.UpdatedAt,
|
|
||||||
task.IsAgentTask,
|
|
||||||
task.ExpectedFrom,
|
|
||||||
last?.Message,
|
|
||||||
last?.CreatedAt,
|
|
||||||
null,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
task.ParentTaskId.HasValue || task.IsAgentTask);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildActivityMessage(
|
private static string BuildActivityMessage(
|
||||||
@@ -190,20 +183,9 @@ public sealed class StaleTaskRecoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatDuration(TimeSpan duration)
|
private static string FormatDuration(TimeSpan duration)
|
||||||
=> duration.ToString(@"dd\.hh\:mm\:ss");
|
|
||||||
|
|
||||||
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
|
|
||||||
{
|
{
|
||||||
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
|
if (duration.TotalHours >= 1)
|
||||||
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
|
return $"{(int)duration.TotalHours}h {duration.Minutes}min";
|
||||||
|
return $"{Math.Max(0, (int)duration.TotalMinutes)}min";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"high" => 3,
|
|
||||||
"medium" => 2,
|
|
||||||
"normal" => 2,
|
|
||||||
"low" => 1,
|
|
||||||
_ => 2
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,13 +214,7 @@ public sealed class TaskBridgeService(
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
||||||
Guid parentTaskId, CancellationToken ct = default)
|
Guid parentTaskId, CancellationToken ct = default)
|
||||||
{
|
=> await taskService.GetChildTaskDtosAsync(parentTaskId, ct);
|
||||||
var board = await taskService.GetBoardAsync(ct);
|
|
||||||
return FlattenBoard(board)
|
|
||||||
.Where(task => task.ParentTaskId == parentTaskId)
|
|
||||||
.OrderByDescending(task => task.UpdatedAt)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
|
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
|
||||||
Guid taskId, CancellationToken ct = default)
|
Guid taskId, CancellationToken ct = default)
|
||||||
@@ -250,13 +244,6 @@ public sealed class TaskBridgeService(
|
|||||||
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
|
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
|
|
||||||
=> board.Offen
|
|
||||||
.Concat(board.InProgress)
|
|
||||||
.Concat(board.Review)
|
|
||||||
.Concat(board.Blocked)
|
|
||||||
.Concat(board.Done);
|
|
||||||
|
|
||||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||||
|
|||||||
@@ -422,6 +422,19 @@ public sealed class TaskService(
|
|||||||
{
|
{
|
||||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||||
|
return BuildMasterBoard(all, activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baut das Board aus NUR den Master-Tasks (Top-Level). Child-Tasks erscheinen
|
||||||
|
/// nicht als eigene Karten, sondern verschachtelt in ihrem Parent — so bleibt das
|
||||||
|
/// Board übersichtlich, auch wenn Iris eine große Aufgabe in viele Teilaufgaben
|
||||||
|
/// zerlegt. Waisen (Parent existiert nicht mehr) werden als Master behandelt,
|
||||||
|
/// damit nichts unsichtbar wird.
|
||||||
|
/// </summary>
|
||||||
|
internal static BoardResponse BuildMasterBoard(IReadOnlyList<WorkTask> all, IReadOnlyList<ActivityEvent> activity)
|
||||||
|
{
|
||||||
|
var ids = all.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
var offen = new List<DashboardTaskDto>();
|
var offen = new List<DashboardTaskDto>();
|
||||||
var inProgress = new List<DashboardTaskDto>();
|
var inProgress = new List<DashboardTaskDto>();
|
||||||
@@ -431,9 +444,10 @@ public sealed class TaskService(
|
|||||||
|
|
||||||
foreach (var task in all)
|
foreach (var task in all)
|
||||||
{
|
{
|
||||||
// Ohne verschachtelte Child-DTOs: Children sind als eigene Karten im Board,
|
var isMaster = !task.ParentTaskId.HasValue || !ids.Contains(task.ParentTaskId.Value);
|
||||||
// die Nested-Duplikate haben die Payload nur verdoppelt (Counts bleiben).
|
if (!isMaster) continue;
|
||||||
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: false);
|
|
||||||
|
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: true);
|
||||||
switch (task.State.ToLowerInvariant())
|
switch (task.State.ToLowerInvariant())
|
||||||
{
|
{
|
||||||
case "backlog": offen.Add(dto); break;
|
case "backlog": offen.Add(dto); break;
|
||||||
@@ -492,6 +506,78 @@ public sealed class TaskService(
|
|||||||
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
|
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review-Abnahme durch Bao/Iris: Review → Done. Nur aus dem Review-Status erlaubt.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
task.ExpectedFrom = null;
|
||||||
|
return await UpdateTaskStatusInternalAsync(
|
||||||
|
task,
|
||||||
|
TaskStateHelper.ToStateString(TaskState.Done),
|
||||||
|
caller,
|
||||||
|
"review",
|
||||||
|
$"Review abgenommen von {caller}: \"{task.Title}\" → Done",
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Änderung anfordern: Review → Zielspalte (Default In progress) mit Pflichtkommentar.
|
||||||
|
/// Setzt ExpectedFrom=iris und benachrichtigt sie, damit sie autonom nacharbeitet.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
var target = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(targetState, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||||
|
// Aus dem Review geht es zurück in die Arbeit — nie direkt nach Done oder Review.
|
||||||
|
if (string.Equals(target, "Done", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(target, "Review", StringComparison.OrdinalIgnoreCase))
|
||||||
|
target = TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||||
|
|
||||||
|
var trimmed = comment.Trim();
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "review_changes_requested",
|
||||||
|
Message = $"Änderung angefordert von {caller}: {trimmed}",
|
||||||
|
TaskId = task.Id
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
task.ExpectedFrom = "iris";
|
||||||
|
var result = await UpdateTaskStatusInternalAsync(
|
||||||
|
task, target, caller, "review",
|
||||||
|
$"Review zurückgegeben von {caller} → {target}", ct);
|
||||||
|
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_changes_requested",
|
||||||
|
$"Änderung angefordert: {task.Title}",
|
||||||
|
trimmed,
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var normalizedHours = Math.Max(1, staleHours);
|
var normalizedHours = Math.Max(1, staleHours);
|
||||||
@@ -509,20 +595,33 @@ public sealed class TaskService(
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Child-Tasks eines Parents als DTOs — direkt aus dem Repo, nicht aus dem Board
|
||||||
|
/// (das zeigt Children ja nur noch verschachtelt an). Für Detailansicht + Bridge.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||||
|
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||||
|
return all.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.OrderByDescending(t => t.UpdatedAt)
|
||||||
|
.Select(child => MapToDtoWithChildren(child, all, activity, includeChildren: false))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
|
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var all = await activityRepo.GetRecentAsync(100, ct);
|
var all = await activityRepo.GetRecentAsync(100, ct);
|
||||||
return all.Where(e => e.TaskId == taskId).ToList();
|
return all.Where(e => e.TaskId == taskId).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
|
private static DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
|
||||||
{
|
{
|
||||||
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
|
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
|
||||||
.OrderByDescending(t => t.UpdatedAt)
|
.OrderByDescending(t => t.UpdatedAt)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// includeChildren=false (Board/SSE-Snapshot): Children erscheinen dort ohnehin
|
// includeChildren=false: nur Zähler, keine verschachtelten Child-DTOs (schlanke Payload).
|
||||||
// als eigene Karten — verschachtelte Child-DTOs verdoppeln nur die Payload.
|
|
||||||
var childDtos = includeChildren
|
var childDtos = includeChildren
|
||||||
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
|
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
|
||||||
: null;
|
: null;
|
||||||
@@ -534,6 +633,7 @@ public sealed class TaskService(
|
|||||||
ChildTasks = childDtos,
|
ChildTasks = childDtos,
|
||||||
ChildTaskCount = childTasks.Count,
|
ChildTaskCount = childTasks.Count,
|
||||||
OpenChildTaskCount = openChildTaskCount,
|
OpenChildTaskCount = openChildTaskCount,
|
||||||
|
DoneChildTaskCount = childTasks.Count - openChildTaskCount,
|
||||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
|
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,9 @@
|
|||||||
"RefreshTokenExpirationDays": 7
|
"RefreshTokenExpirationDays": 7
|
||||||
},
|
},
|
||||||
"TaskRecovery": {
|
"TaskRecovery": {
|
||||||
"StaleHours": 2,
|
"StalledMinutes": 40,
|
||||||
"IntervalMinutes": 30
|
"IntervalMinutes": 10,
|
||||||
|
"StaleHours": 2
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
<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: #c084fc; }
|
||||||
|
.ball.is-bao { background: rgba(59,130,246,.16); color: #60a5fa; }
|
||||||
|
.ball.is-agent { background: rgba(16,185,129,.14); color: #6ee7b7; }
|
||||||
|
.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: #c084fc; }
|
||||||
|
.child-agent.is-bao { background: rgba(59,130,246,.14); color: #60a5fa; }
|
||||||
|
.child-agent.is-agent { background: rgba(16,185,129,.12); color: #6ee7b7; }
|
||||||
|
.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: #fdba74; }
|
||||||
|
.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: #fdba74; }
|
||||||
|
.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>
|
||||||
@@ -30,6 +30,7 @@ export interface DashboardTaskDto {
|
|||||||
lastActivityMessage?: string | null
|
lastActivityMessage?: string | null
|
||||||
lastActivityAt?: string | null
|
lastActivityAt?: string | null
|
||||||
childTasks?: DashboardTaskDto[] | null
|
childTasks?: DashboardTaskDto[] | null
|
||||||
|
doneChildTaskCount?: number
|
||||||
childTaskCount?: number
|
childTaskCount?: number
|
||||||
openChildTaskCount?: number
|
openChildTaskCount?: number
|
||||||
hasVisibleDelegation?: boolean
|
hasVisibleDelegation?: boolean
|
||||||
@@ -215,6 +216,29 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* ── API: Review abnehmen (Review → Done) ─────── */
|
||||||
|
async approveReview(id: string) {
|
||||||
|
const res = await apiFetch(`/api/dashboard/tasks/${id}/approve`, { method: 'POST' })
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(body.error || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
await this.fetchBoard()
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── API: Änderung anfordern (Review → Zielspalte) ── */
|
||||||
|
async requestChanges(id: string, comment: string, targetState = 'In progress') {
|
||||||
|
const res = await apiFetch(`/api/dashboard/tasks/${id}/request-changes`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ comment, targetState }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(body.error || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
await this.fetchBoard()
|
||||||
|
},
|
||||||
|
|
||||||
/* ── API: Create task ─────────────────────────── */
|
/* ── API: Create task ─────────────────────────── */
|
||||||
async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) {
|
async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,12 +13,17 @@
|
|||||||
* - Waiting section for Iris overview
|
* - Waiting section for Iris overview
|
||||||
*/
|
*/
|
||||||
import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText } from '@lucide/vue'
|
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText, RotateCcw } from '@lucide/vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useTaskStore } from '../stores/tasks'
|
import { useTaskStore, type DashboardTaskDto } from '../stores/tasks'
|
||||||
import { useLiveSyncStore } from '../stores/liveSync'
|
import { useLiveSyncStore } from '../stores/liveSync'
|
||||||
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
|
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
|
||||||
|
import BoardCard from '../components/board/BoardCard.vue'
|
||||||
|
|
||||||
|
/** 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 BoardTask = ReturnType<typeof flattenBoard>[number]
|
||||||
|
|
||||||
@@ -95,6 +100,46 @@ async function handleCreateTask() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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 ────────────────────────────────── */
|
/* ── Drag & Drop ────────────────────────────────── */
|
||||||
const draggedTaskId = ref<string | null>(null)
|
const draggedTaskId = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -141,20 +186,6 @@ async function onDrop(e: DragEvent, targetState: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Helpers ──────────────────────────────────────── */
|
/* ── Helpers ──────────────────────────────────────── */
|
||||||
function priorityLabel(p: string): string {
|
|
||||||
const lower = p.toLowerCase()
|
|
||||||
if (lower === 'high') return 'High'
|
|
||||||
if (lower === 'low') return 'Low'
|
|
||||||
return 'Med'
|
|
||||||
}
|
|
||||||
|
|
||||||
function priorityColor(p: string): string {
|
|
||||||
const lower = p.toLowerCase()
|
|
||||||
if (lower === 'high') return '#f87171'
|
|
||||||
if (lower === 'low') return '#60a5fa'
|
|
||||||
return '#facc15'
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusTone(state: string): string {
|
function statusTone(state: string): string {
|
||||||
switch (state.toLowerCase()) {
|
switch (state.toLowerCase()) {
|
||||||
case 'done': return 'is-done'
|
case 'done': return 'is-done'
|
||||||
@@ -202,6 +233,15 @@ const canSaveDetail = computed(() => detailForm.title.trim().length > 0 && !deta
|
|||||||
*/
|
*/
|
||||||
const canChangeState = computed(() => authStore.isIris || authStore.isBao)
|
const canChangeState = computed(() => authStore.isIris || authStore.isBao)
|
||||||
|
|
||||||
|
/* 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)' },
|
||||||
|
])
|
||||||
|
|
||||||
function hydrateDetailForm(task: BoardTask | null) {
|
function hydrateDetailForm(task: BoardTask | null) {
|
||||||
detailError.value = ''
|
detailError.value = ''
|
||||||
detailSuccess.value = ''
|
detailSuccess.value = ''
|
||||||
@@ -283,18 +323,6 @@ function delegationBadge(task: BoardTask): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function assigneeLabel(assignedTo: string | null | undefined): string {
|
|
||||||
return expectedFromLabel(assignedTo)
|
|
||||||
}
|
|
||||||
|
|
||||||
function assigneeClass(assignedTo: string | null | undefined): string {
|
|
||||||
if (!assignedTo) return ''
|
|
||||||
const lower = assignedTo.toLowerCase()
|
|
||||||
if (lower === 'iris') return 'assignee-iris'
|
|
||||||
if (lower === 'bao') return 'assignee-bao'
|
|
||||||
return 'assignee-agent'
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Task Navigation ───────────────────────────── */
|
/* ── Task Navigation ───────────────────────────── */
|
||||||
function navigateToTask(taskId: string) {
|
function navigateToTask(taskId: string) {
|
||||||
router.push('/tasks/' + taskId)
|
router.push('/tasks/' + taskId)
|
||||||
@@ -306,15 +334,6 @@ async function openQuickPeek(taskId: string) {
|
|||||||
await loadDetailContext(taskId)
|
await loadDetailContext(taskId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCardClick(event: MouseEvent, taskId: string) {
|
|
||||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
|
||||||
event.preventDefault()
|
|
||||||
openQuickPeek(taskId)
|
|
||||||
} else {
|
|
||||||
navigateToTask(taskId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDetailPanel() {
|
function closeDetailPanel() {
|
||||||
showDetailPanel.value = false
|
showDetailPanel.value = false
|
||||||
selectedTaskId.value = null
|
selectedTaskId.value = null
|
||||||
@@ -527,254 +546,34 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div v-else class="board-columns">
|
<div v-else class="board-columns">
|
||||||
<div
|
<div
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.key"
|
||||||
class="col"
|
class="col"
|
||||||
:class="{ 'drag-over': dragOverColumn === 'offen' }"
|
:class="{ 'drag-over': dragOverColumn === col.key, 'col-blocked': col.key === 'blocked' }"
|
||||||
@dragover="onDragOver($event, 'offen')"
|
@dragover="onDragOver($event, col.key)"
|
||||||
@dragleave="onDragLeave"
|
@dragleave="onDragLeave"
|
||||||
@drop="onDrop($event, 'offen')"
|
@drop="onDrop($event, col.key)"
|
||||||
>
|
>
|
||||||
<div class="col-header">
|
<div class="col-header">
|
||||||
<span class="col-icon" style="background: var(--st-queue); box-shadow: 0 0 0 2px rgba(251,191,36,.25);"></span>
|
<span class="col-icon" :style="{ background: col.dot, boxShadow: `0 0 0 2px ${col.ring}` }"></span>
|
||||||
<span class="col-name">Offen</span>
|
<span class="col-name">{{ col.name }}</span>
|
||||||
<span class="col-count">{{ taskStore.board.offen.length }}</span>
|
<span class="col-count">{{ col.tasks.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-cards">
|
<div class="col-cards">
|
||||||
<button
|
<BoardCard
|
||||||
v-for="task in taskStore.board.offen"
|
v-for="task in col.tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
type="button"
|
:task="task"
|
||||||
class="card"
|
:column="col.key"
|
||||||
:class="{ 'card-agent': task.isAgentTask }"
|
:can-review="canChangeState"
|
||||||
draggable="true"
|
:stall-threshold-min="STALL_THRESHOLD_MIN"
|
||||||
@click="handleCardClick($event, task.id)"
|
@open="openQuickPeek"
|
||||||
@dragstart="onDragStart($event, task.id)"
|
@approve="handleApprove"
|
||||||
|
@request-changes="openRequestChanges"
|
||||||
|
@dragstart="onDragStart"
|
||||||
@dragend="onDragEnd"
|
@dragend="onDragEnd"
|
||||||
>
|
/>
|
||||||
<div class="card-top">
|
<div v-if="!col.tasks.length" class="empty-col">Keine Aufgaben</div>
|
||||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
|
||||||
{{ priorityLabel(task.priority) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
|
||||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
|
||||||
⏳ {{ task.expectedFrom }}
|
|
||||||
</span>
|
|
||||||
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
|
||||||
↳ {{ delegationBadge(task) }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="task.assignedTo"
|
|
||||||
class="assignee"
|
|
||||||
:class="assigneeClass(task.assignedTo)"
|
|
||||||
>
|
|
||||||
{{ assigneeLabel(task.assignedTo) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-title">{{ task.title }}</div>
|
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
|
||||||
<div v-if="task.isAgentTask || hasChildTasks(task.id)" 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="col"
|
|
||||||
:class="{ 'drag-over': dragOverColumn === 'inProgress' }"
|
|
||||||
@dragover="onDragOver($event, 'inProgress')"
|
|
||||||
@dragleave="onDragLeave"
|
|
||||||
@drop="onDrop($event, 'inProgress')"
|
|
||||||
>
|
|
||||||
<div class="col-header">
|
|
||||||
<span class="col-icon" style="background: var(--st-work); box-shadow: 0 0 0 2px rgba(61,220,151,.25);"></span>
|
|
||||||
<span class="col-name">In Bearbeitung</span>
|
|
||||||
<span class="col-count">{{ taskStore.board.inProgress.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-cards">
|
|
||||||
<button
|
|
||||||
v-for="task in taskStore.board.inProgress"
|
|
||||||
:key="task.id"
|
|
||||||
type="button"
|
|
||||||
class="card"
|
|
||||||
:class="{ 'card-agent': task.isAgentTask }"
|
|
||||||
draggable="true"
|
|
||||||
@click="handleCardClick($event, task.id)"
|
|
||||||
@dragstart="onDragStart($event, task.id)"
|
|
||||||
@dragend="onDragEnd"
|
|
||||||
>
|
|
||||||
<div class="card-top">
|
|
||||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
|
||||||
{{ priorityLabel(task.priority) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
|
||||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
|
||||||
⏳ {{ task.expectedFrom }}
|
|
||||||
</span>
|
|
||||||
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
|
||||||
↳ {{ delegationBadge(task) }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="task.assignedTo"
|
|
||||||
class="assignee"
|
|
||||||
:class="assigneeClass(task.assignedTo)"
|
|
||||||
>
|
|
||||||
{{ assigneeLabel(task.assignedTo) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-title">{{ task.title }}</div>
|
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
|
||||||
<div v-if="task.isAgentTask || hasChildTasks(task.id)" 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="col"
|
|
||||||
:class="{ 'drag-over': dragOverColumn === 'review' }"
|
|
||||||
@dragover="onDragOver($event, 'review')"
|
|
||||||
@dragleave="onDragLeave"
|
|
||||||
@drop="onDrop($event, 'review')"
|
|
||||||
>
|
|
||||||
<div class="col-header">
|
|
||||||
<span class="col-icon" style="background: #fb923c; box-shadow: 0 0 0 2px rgba(251,146,60,.25);"></span>
|
|
||||||
<span class="col-name">Review</span>
|
|
||||||
<span class="col-count">{{ taskStore.board.review.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-cards">
|
|
||||||
<button
|
|
||||||
v-for="task in taskStore.board.review"
|
|
||||||
:key="task.id"
|
|
||||||
type="button"
|
|
||||||
class="card"
|
|
||||||
:class="{ 'card-agent': task.isAgentTask }"
|
|
||||||
draggable="true"
|
|
||||||
@click="handleCardClick($event, task.id)"
|
|
||||||
@dragstart="onDragStart($event, task.id)"
|
|
||||||
@dragend="onDragEnd"
|
|
||||||
>
|
|
||||||
<div class="card-top">
|
|
||||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
|
||||||
{{ priorityLabel(task.priority) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
|
||||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
|
||||||
⏳ {{ task.expectedFrom }}
|
|
||||||
</span>
|
|
||||||
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
|
||||||
↳ {{ delegationBadge(task) }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="task.assignedTo"
|
|
||||||
class="assignee"
|
|
||||||
:class="assigneeClass(task.assignedTo)"
|
|
||||||
>
|
|
||||||
{{ assigneeLabel(task.assignedTo) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-title">{{ task.title }}</div>
|
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
|
||||||
<div v-if="task.isAgentTask || hasChildTasks(task.id)" 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="col"
|
|
||||||
:class="{ 'drag-over': dragOverColumn === 'done' }"
|
|
||||||
@dragover="onDragOver($event, 'done')"
|
|
||||||
@dragleave="onDragLeave"
|
|
||||||
@drop="onDrop($event, 'done')"
|
|
||||||
>
|
|
||||||
<div class="col-header">
|
|
||||||
<span class="col-icon" style="background: var(--st-work); box-shadow: 0 0 0 2px rgba(61,220,151,.25);"></span>
|
|
||||||
<span class="col-name">Erledigt</span>
|
|
||||||
<span class="col-count">{{ taskStore.board.done.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-cards">
|
|
||||||
<button
|
|
||||||
v-for="task in taskStore.board.done"
|
|
||||||
:key="task.id"
|
|
||||||
type="button"
|
|
||||||
class="card"
|
|
||||||
draggable="true"
|
|
||||||
@click="handleCardClick($event, task.id)"
|
|
||||||
@dragstart="onDragStart($event, task.id)"
|
|
||||||
@dragend="onDragEnd"
|
|
||||||
>
|
|
||||||
<div class="card-top">
|
|
||||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
|
||||||
{{ priorityLabel(task.priority) }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="task.assignedTo"
|
|
||||||
class="assignee"
|
|
||||||
:class="assigneeClass(task.assignedTo)"
|
|
||||||
>
|
|
||||||
{{ assigneeLabel(task.assignedTo) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-title">{{ task.title }}</div>
|
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
|
||||||
<div v-if="task.isAgentTask || hasChildTasks(task.id)" 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="col col-blocked"
|
|
||||||
:class="{ 'drag-over': dragOverColumn === 'blocked' }"
|
|
||||||
@dragover="onDragOver($event, 'blocked')"
|
|
||||||
@dragleave="onDragLeave"
|
|
||||||
@drop="onDrop($event, 'blocked')"
|
|
||||||
>
|
|
||||||
<div class="col-header">
|
|
||||||
<span class="col-icon" style="background: var(--st-block); box-shadow: 0 0 0 2px rgba(251,113,133,.25);"></span>
|
|
||||||
<span class="col-name">Blockiert</span>
|
|
||||||
<span class="col-count">{{ taskStore.board.blocked.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-cards">
|
|
||||||
<button
|
|
||||||
v-for="task in taskStore.board.blocked"
|
|
||||||
:key="task.id"
|
|
||||||
type="button"
|
|
||||||
class="card card-blocked"
|
|
||||||
:class="{ 'card-agent': task.isAgentTask }"
|
|
||||||
draggable="true"
|
|
||||||
@click="handleCardClick($event, task.id)"
|
|
||||||
@dragstart="onDragStart($event, task.id)"
|
|
||||||
@dragend="onDragEnd"
|
|
||||||
>
|
|
||||||
<div class="card-top">
|
|
||||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
|
||||||
{{ priorityLabel(task.priority) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
|
||||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
|
||||||
⏳ {{ task.expectedFrom }}
|
|
||||||
</span>
|
|
||||||
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
|
||||||
↳ {{ delegationBadge(task) }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="task.assignedTo"
|
|
||||||
class="assignee"
|
|
||||||
:class="assigneeClass(task.assignedTo)"
|
|
||||||
>
|
|
||||||
{{ assigneeLabel(task.assignedTo) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-title">{{ task.title }}</div>
|
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
|
||||||
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -841,6 +640,45 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</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">
|
<Teleport to="body">
|
||||||
<div v-if="showDetailPanel && selectedTask" class="detail-overlay" @click.self="closeDetailPanel">
|
<div v-if="showDetailPanel && selectedTask" class="detail-overlay" @click.self="closeDetailPanel">
|
||||||
<aside class="detail-panel">
|
<aside class="detail-panel">
|
||||||
|
|||||||
Reference in New Issue
Block a user