feat: ship agent progress visibility
This commit is contained in:
@@ -262,9 +262,9 @@ public class DashboardController(
|
|||||||
[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)
|
||||||
{
|
{
|
||||||
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." });
|
if (task is null) return NotFound(new { error = "Task not found." });
|
||||||
return Ok(MapToDto(task));
|
return Ok(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("tasks/{id:guid}/activity")]
|
[HttpGet("tasks/{id:guid}/activity")]
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ public sealed record DashboardTaskDto(
|
|||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset UpdatedAt,
|
DateTimeOffset UpdatedAt,
|
||||||
bool IsAgentTask = false,
|
bool IsAgentTask = false,
|
||||||
string? ExpectedFrom = null
|
string? ExpectedFrom = null,
|
||||||
|
string? LastActivityMessage = null,
|
||||||
|
DateTimeOffset? LastActivityAt = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record CreateDashboardTaskRequest(
|
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)
|
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||||
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
|
=> 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(
|
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
|
|||||||
public interface IActivityRepository
|
public interface IActivityRepository
|
||||||
{
|
{
|
||||||
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
|
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(
|
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
|
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
|
||||||
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, 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<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<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
||||||
|
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
||||||
// Agent Workflow Overview
|
// Agent Workflow Overview
|
||||||
Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default);
|
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)
|
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
=> await taskRepo.GetByIdAsync(id, ct);
|
=> 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)
|
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||||
=> await taskRepo.GetPendingApprovalAsync(ct);
|
=> await taskRepo.GetPendingApprovalAsync(ct);
|
||||||
|
|
||||||
@@ -162,35 +171,32 @@ public sealed class TaskService(
|
|||||||
|
|
||||||
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
|
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) &&
|
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
|
||||||
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
|
||||||
.Select(MapToDto)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var waitingForIris = agentTasks
|
var waitingForIris = map(agentTasks
|
||||||
.Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) &&
|
.Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) &&
|
||||||
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
|
||||||
.Select(MapToDto)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var waitingForOthers = agentTasks
|
var waitingForOthers = map(agentTasks
|
||||||
.Where(t =>
|
.Where(t =>
|
||||||
{
|
{
|
||||||
var expected = (t.ExpectedFrom ?? "").ToLowerInvariant();
|
var expected = (t.ExpectedFrom ?? "").ToLowerInvariant();
|
||||||
return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) &&
|
return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) &&
|
||||||
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase);
|
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase);
|
||||||
})
|
}));
|
||||||
.Select(MapToDto)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var staleTasks = agentTasks
|
var staleTasks = map(agentTasks
|
||||||
.Where(t =>
|
.Where(t =>
|
||||||
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
|
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
|
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
|
||||||
t.UpdatedAt < threshold)
|
t.UpdatedAt < threshold));
|
||||||
.Select(MapToDto)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
|
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
|
||||||
staleTasks, staleThreshold);
|
staleTasks, staleThreshold);
|
||||||
@@ -533,6 +539,21 @@ public sealed class TaskService(
|
|||||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||||
t.IsAgentTask, t.ExpectedFrom);
|
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>
|
/// <summary>
|
||||||
/// Validates AssignedTo — only recognized agent values are accepted.
|
/// Validates AssignedTo — only recognized agent values are accepted.
|
||||||
/// Returns null for invalid values.
|
/// Returns null for invalid values.
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface DashboardTaskDto {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
isAgentTask?: boolean
|
isAgentTask?: boolean
|
||||||
expectedFrom?: string | null
|
expectedFrom?: string | null
|
||||||
|
lastActivityMessage?: string | null
|
||||||
|
lastActivityAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardGroup {
|
export interface BoardGroup {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* - 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 } 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 { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useTaskStore } from '../stores/tasks'
|
import { useTaskStore } from '../stores/tasks'
|
||||||
@@ -238,6 +238,22 @@ function hoursSince(dateStr: string): number {
|
|||||||
return Math.round((now - then) / 3600000)
|
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 ───────────────────────────── */
|
/* ── Task Navigation ───────────────────────────── */
|
||||||
function navigateToTask(taskId: string) {
|
function navigateToTask(taskId: string) {
|
||||||
router.push('/tasks/' + taskId)
|
router.push('/tasks/' + taskId)
|
||||||
@@ -401,9 +417,12 @@ onUnmounted(() => {
|
|||||||
Warte auf Iris <span class="section-count">{{ waitingForIrisCount }}</span>
|
Warte auf Iris <span class="section-count">{{ waitingForIrisCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="taskStore.waitingForIrisTasks.length === 0" class="iris-empty">Keine Tasks</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">
|
<div v-for="t in taskStore.waitingForIrisTasks" :key="t.id" class="iris-task-row progress-row">
|
||||||
<span class="iris-task-title">{{ t.title }}</span>
|
<div>
|
||||||
<span class="iris-task-meta">{{ stateLabel(t.state) }}</span>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -413,9 +432,12 @@ onUnmounted(() => {
|
|||||||
Warte auf Bao <span class="section-count">{{ waitingForBaoCount }}</span>
|
Warte auf Bao <span class="section-count">{{ waitingForBaoCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="taskStore.waitingForBaoTasks.length === 0" class="iris-empty">Keine Tasks</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">
|
<div v-for="t in taskStore.waitingForBaoTasks" :key="t.id" class="iris-task-row progress-row">
|
||||||
<span class="iris-task-title">{{ t.title }}</span>
|
<div>
|
||||||
<span class="iris-task-meta">{{ stateLabel(t.state) }}</span>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -425,8 +447,11 @@ onUnmounted(() => {
|
|||||||
Warte auf andere <span class="section-count">{{ taskStore.waitingForOthersTasks.length }}</span>
|
Warte auf andere <span class="section-count">{{ taskStore.waitingForOthersTasks.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="taskStore.waitingForOthersTasks.length === 0" class="iris-empty">Keine Tasks</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">
|
<div v-for="t in taskStore.waitingForOthersTasks" :key="t.id" class="iris-task-row progress-row">
|
||||||
<span class="iris-task-title">{{ t.title }}</span>
|
<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>
|
<span class="iris-task-meta">{{ expectedFromLabel(t.expectedFrom) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -437,8 +462,11 @@ onUnmounted(() => {
|
|||||||
Stale Tasks <span class="section-count stale-count">{{ staleCount }}</span>
|
Stale Tasks <span class="section-count stale-count">{{ staleCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="taskStore.staleTasksList.length === 0" class="iris-empty">Keine stale Tasks</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">
|
<div v-for="t in taskStore.staleTasksList" :key="t.id" class="iris-task-row stale-row progress-row">
|
||||||
<span class="iris-task-title">{{ t.title }}</span>
|
<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>
|
<span class="iris-task-meta stale-meta">{{ hoursSince(t.updatedAt) }}h offen</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -493,7 +521,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-title">{{ task.title }}</div>
|
<div class="card-title">{{ task.title }}</div>
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</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>
|
</button>
|
||||||
<div v-if="!taskStore.board.offen.length" class="empty-col">Keine Aufgaben</div>
|
<div v-if="!taskStore.board.offen.length" class="empty-col">Keine Aufgaben</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -541,7 +570,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-title">{{ task.title }}</div>
|
<div class="card-title">{{ task.title }}</div>
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</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>
|
</button>
|
||||||
<div v-if="!taskStore.board.inProgress.length" class="empty-col">Keine Aufgaben</div>
|
<div v-if="!taskStore.board.inProgress.length" class="empty-col">Keine Aufgaben</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -589,7 +619,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-title">{{ task.title }}</div>
|
<div class="card-title">{{ task.title }}</div>
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</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>
|
</button>
|
||||||
<div v-if="!taskStore.board.delegated.length" class="empty-col">Keine delegierten Aufgaben</div>
|
<div v-if="!taskStore.board.delegated.length" class="empty-col">Keine delegierten Aufgaben</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -637,7 +668,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-title">{{ task.title }}</div>
|
<div class="card-title">{{ task.title }}</div>
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</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>
|
</button>
|
||||||
<div v-if="!taskStore.board.review.length" class="empty-col">Keine Aufgaben</div>
|
<div v-if="!taskStore.board.review.length" class="empty-col">Keine Aufgaben</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -680,7 +712,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-title">{{ task.title }}</div>
|
<div class="card-title">{{ task.title }}</div>
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</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>
|
</button>
|
||||||
<div v-if="!taskStore.board.done.length" class="empty-col">Keine Aufgaben</div>
|
<div v-if="!taskStore.board.done.length" class="empty-col">Keine Aufgaben</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -728,7 +761,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-title">{{ task.title }}</div>
|
<div class="card-title">{{ task.title }}</div>
|
||||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</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>
|
</button>
|
||||||
<div v-if="!taskStore.board.blocked.length" class="empty-col">Keine Blockierer</div>
|
<div v-if="!taskStore.board.blocked.length" class="empty-col">Keine Blockierer</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -818,6 +851,10 @@ onUnmounted(() => {
|
|||||||
<span v-if="selectedTask.expectedFrom" class="meta-expected">⏳ Erwartet: {{ selectedTask.expectedFrom }}</span>
|
<span v-if="selectedTask.expectedFrom" class="meta-expected">⏳ Erwartet: {{ selectedTask.expectedFrom }}</span>
|
||||||
<span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span>
|
<span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span>
|
||||||
<span><CalendarDays :size="13" /> Erstellt {{ formatDate(selectedTask.createdAt) }}</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>
|
||||||
</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-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 { 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-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; }
|
.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-row { background: rgba(244,63,94,.05); border-radius: 4px; }
|
||||||
.stale-meta { color: #fda4af; font-weight: 600; }
|
.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; }
|
.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-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-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; }
|
.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; }
|
.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; }
|
.detail-meta-row span { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
.meta-agent-tag { color: #c084fc; }
|
.meta-agent-tag { color: #c084fc; }
|
||||||
.meta-expected { color: #a78bfa; }
|
.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 { 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-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; }
|
.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 { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Clock3, CalendarDays, ListChecks, Save, Plus,
|
ArrowLeft, Clock3, CalendarDays, ListChecks, Save, Plus,
|
||||||
X, Link2, MessageSquare, Trash2, CheckCircle, AlertCircle,
|
X, Link2, MessageSquare, Trash2, CheckCircle, AlertCircle, ShieldBan,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { apiFetch } from '../services/api'
|
import { apiFetch } from '../services/api'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
/* ── Types ──────────────────────────────────── */
|
/* ── Types ──────────────────────────────────── */
|
||||||
interface TaskDto {
|
interface TaskDto {
|
||||||
@@ -29,6 +30,10 @@ interface TaskDto {
|
|||||||
dueDate: string | null
|
dueDate: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
isAgentTask?: boolean
|
||||||
|
expectedFrom?: string | null
|
||||||
|
lastActivityMessage?: string | null
|
||||||
|
lastActivityAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityEntry {
|
interface ActivityEntry {
|
||||||
@@ -42,6 +47,7 @@ interface ActivityEntry {
|
|||||||
/* ── State ───────────────────────────────────── */
|
/* ── State ───────────────────────────────────── */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const taskId = computed(() => route.params.id as string)
|
const taskId = computed(() => route.params.id as string)
|
||||||
|
|
||||||
const task = ref<TaskDto | null>(null)
|
const task = ref<TaskDto | null>(null)
|
||||||
@@ -79,6 +85,7 @@ const postingComment = ref(false)
|
|||||||
|
|
||||||
/* ── Delete state ───────────────────────────── */
|
/* ── Delete state ───────────────────────────── */
|
||||||
const deletingTask = ref('') // id of subtask being deleted
|
const deletingTask = ref('') // id of subtask being deleted
|
||||||
|
const canChangeState = computed(() => authStore.isIris || authStore.isBao)
|
||||||
|
|
||||||
/* ── Helpers ────────────────────────────────── */
|
/* ── Helpers ────────────────────────────────── */
|
||||||
function statusLabel(state: string): string {
|
function statusLabel(state: string): string {
|
||||||
@@ -129,6 +136,22 @@ function toDateInput(date?: string | null): string {
|
|||||||
return new Date(date).toISOString().slice(0, 10)
|
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 ───────────────────────────────── */
|
/* ── API calls ───────────────────────────────── */
|
||||||
async function loadTask() {
|
async function loadTask() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -188,12 +211,16 @@ async function saveTask() {
|
|||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
// Also update state if changed
|
// Also update state if changed
|
||||||
if (form.state !== task.value.state) {
|
if (form.state !== task.value.state) {
|
||||||
const stateRes = await apiFetch(`/api/dashboard/tasks/${taskId.value}/status`, {
|
if (!canChangeState.value) {
|
||||||
method: 'PATCH',
|
form.state = task.value.state
|
||||||
headers: { 'Content-Type': 'application/json' },
|
} else {
|
||||||
body: JSON.stringify({ status: form.state }),
|
const stateRes = await apiFetch(`/api/dashboard/tasks/${taskId.value}/status`, {
|
||||||
})
|
method: 'PATCH',
|
||||||
if (!stateRes.ok) throw new Error('Status-Update fehlgeschlagen')
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: form.state }),
|
||||||
|
})
|
||||||
|
if (!stateRes.ok) throw new Error('Status-Update fehlgeschlagen')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
saveSuccess.value = true
|
saveSuccess.value = true
|
||||||
await loadTask()
|
await loadTask()
|
||||||
@@ -236,6 +263,7 @@ async function createSubtask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateSubtaskState(subtaskId: string, newState: string) {
|
async function updateSubtaskState(subtaskId: string, newState: string) {
|
||||||
|
if (!canChangeState.value) return
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/dashboard/tasks/${subtaskId}/status`, {
|
await apiFetch(`/api/dashboard/tasks/${subtaskId}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -332,9 +360,16 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<span class="state-badge" :class="statusClass(task.state)">{{ statusLabel(task.state) }}</span>
|
<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" v-if="task.source">Quelle: {{ task.source }}</span>
|
||||||
<span class="meta-chip">{{ task.priority }} Priorität</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>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!canChangeState" class="readonly-banner">
|
||||||
|
<ShieldBan :size="14" />
|
||||||
|
Inhalte sind editierbar, Statuswechsel bleiben Bao/Iris vorbehalten.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="detail-body">
|
<div class="detail-body">
|
||||||
<!-- Main Column -->
|
<!-- Main Column -->
|
||||||
<div class="detail-main">
|
<div class="detail-main">
|
||||||
@@ -349,6 +384,14 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<CalendarDays :size="13" />
|
<CalendarDays :size="13" />
|
||||||
Aktualisiert {{ formatDate(task.updatedAt, true) }}
|
Aktualisiert {{ formatDate(task.updatedAt, true) }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
@@ -407,6 +450,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<select
|
<select
|
||||||
:value="child.state"
|
:value="child.state"
|
||||||
class="state-select-mini"
|
class="state-select-mini"
|
||||||
|
:disabled="!canChangeState"
|
||||||
@change="updateSubtaskState(child.id, ($event.target as HTMLSelectElement).value)"
|
@change="updateSubtaskState(child.id, ($event.target as HTMLSelectElement).value)"
|
||||||
>
|
>
|
||||||
<option value="Backlog">Offen</option>
|
<option value="Backlog">Offen</option>
|
||||||
@@ -473,7 +517,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<div class="sidebar-head">Eigenschaften</div>
|
<div class="sidebar-head">Eigenschaften</div>
|
||||||
<label class="sidebar-field">
|
<label class="sidebar-field">
|
||||||
<span>Status</span>
|
<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="Backlog">Offen</option>
|
||||||
<option value="In progress">In Bearbeitung</option>
|
<option value="In progress">In Bearbeitung</option>
|
||||||
<option value="Delegated">Delegiert</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>Quelle</dt><dd>{{ task.source || '—' }}</dd></div>
|
||||||
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</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><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>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -647,6 +692,19 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border: 1px solid rgba(150, 140, 255, 0.08);
|
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 Grid ────────────────────────── */
|
||||||
.detail-body {
|
.detail-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -694,6 +752,15 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
gap: 5px;
|
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 ──────────────────────────── */
|
/* ── Sections ──────────────────────────── */
|
||||||
.section-card {
|
.section-card {
|
||||||
border: 1px solid rgba(150, 140, 255, 0.10);
|
border: 1px solid rgba(150, 140, 255, 0.10);
|
||||||
@@ -830,6 +897,11 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
color: #ece9ff;
|
color: #ece9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.state-select-mini:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-mini {
|
.loading-mini {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1025,6 +1097,11 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
color: #ece9ff;
|
color: #ece9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.galaxy-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user