1245 lines
42 KiB
C#
1245 lines
42 KiB
C#
using Lagerbestand.Data;
|
|
using Lagerbestand.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Lagerbestand.Services;
|
|
|
|
public class KanbanService : IKanbanService
|
|
{
|
|
private readonly AppDbContext _context;
|
|
private readonly ILogger<KanbanService> _logger;
|
|
private readonly IFileStorageService _fileStorageService;
|
|
|
|
public KanbanService(AppDbContext context, ILogger<KanbanService> logger, IFileStorageService fileStorageService)
|
|
{
|
|
_context = context;
|
|
_logger = logger;
|
|
_fileStorageService = fileStorageService;
|
|
}
|
|
|
|
#region Columns
|
|
|
|
public async Task<List<KanbanColumn>> GetColumnsAsync()
|
|
{
|
|
return await _context.KanbanColumns
|
|
.Where(c => c.IsActive)
|
|
.OrderBy(c => c.SortOrder)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<KanbanColumn?> GetColumnByIdAsync(Guid id)
|
|
{
|
|
return await _context.KanbanColumns.FindAsync(id);
|
|
}
|
|
|
|
public async Task<KanbanColumn> CreateColumnAsync(KanbanColumn column)
|
|
{
|
|
column.Id = Guid.NewGuid();
|
|
column.CreatedAt = DateTime.UtcNow;
|
|
column.UpdatedAt = DateTime.UtcNow;
|
|
|
|
var maxOrder = await _context.KanbanColumns.MaxAsync(c => (int?)c.SortOrder) ?? -1;
|
|
column.SortOrder = maxOrder + 1;
|
|
|
|
_context.KanbanColumns.Add(column);
|
|
await _context.SaveChangesAsync();
|
|
return column;
|
|
}
|
|
|
|
public async Task<KanbanColumn> UpdateColumnAsync(KanbanColumn column)
|
|
{
|
|
var existing = await _context.KanbanColumns.FindAsync(column.Id)
|
|
?? throw new InvalidOperationException("Spalte nicht gefunden.");
|
|
|
|
existing.Name = column.Name;
|
|
existing.Color = column.Color;
|
|
existing.Icon = column.Icon;
|
|
existing.IsDefault = column.IsDefault;
|
|
existing.IsCompletedColumn = column.IsCompletedColumn;
|
|
existing.UpdatedAt = DateTime.UtcNow;
|
|
|
|
// If this is set as default, unset others
|
|
if (column.IsDefault)
|
|
{
|
|
await _context.KanbanColumns
|
|
.Where(c => c.Id != column.Id && c.IsDefault)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(c => c.IsDefault, false));
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
return existing;
|
|
}
|
|
|
|
public async Task DeleteColumnAsync(Guid id)
|
|
{
|
|
var column = await _context.KanbanColumns.FindAsync(id)
|
|
?? throw new InvalidOperationException("Spalte nicht gefunden.");
|
|
|
|
// Check if there are tasks in this column
|
|
var hasActiveTasks = await _context.KanbanTasks
|
|
.AnyAsync(t => t.ColumnId == id && !t.IsArchived);
|
|
|
|
if (hasActiveTasks)
|
|
{
|
|
throw new InvalidOperationException("Spalte enthält noch aktive Aufgaben.");
|
|
}
|
|
|
|
column.IsActive = false;
|
|
column.UpdatedAt = DateTime.UtcNow;
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task ReorderColumnsAsync(List<Guid> columnIds)
|
|
{
|
|
for (int i = 0; i < columnIds.Count; i++)
|
|
{
|
|
await _context.KanbanColumns
|
|
.Where(c => c.Id == columnIds[i])
|
|
.ExecuteUpdateAsync(s => s.SetProperty(c => c.SortOrder, i));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Labels
|
|
|
|
public async Task<List<KanbanLabel>> GetLabelsAsync()
|
|
{
|
|
return await _context.KanbanLabels
|
|
.Where(l => l.IsActive)
|
|
.OrderBy(l => l.SortOrder)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<KanbanLabel?> GetLabelByIdAsync(Guid id)
|
|
{
|
|
return await _context.KanbanLabels.FindAsync(id);
|
|
}
|
|
|
|
public async Task<KanbanLabel> CreateLabelAsync(KanbanLabel label)
|
|
{
|
|
label.Id = Guid.NewGuid();
|
|
label.CreatedAt = DateTime.UtcNow;
|
|
|
|
var maxOrder = await _context.KanbanLabels.MaxAsync(l => (int?)l.SortOrder) ?? -1;
|
|
label.SortOrder = maxOrder + 1;
|
|
|
|
_context.KanbanLabels.Add(label);
|
|
await _context.SaveChangesAsync();
|
|
return label;
|
|
}
|
|
|
|
public async Task<KanbanLabel> UpdateLabelAsync(KanbanLabel label)
|
|
{
|
|
var existing = await _context.KanbanLabels.FindAsync(label.Id)
|
|
?? throw new InvalidOperationException("Label nicht gefunden.");
|
|
|
|
existing.Name = label.Name;
|
|
existing.Color = label.Color;
|
|
existing.Description = label.Description;
|
|
|
|
await _context.SaveChangesAsync();
|
|
return existing;
|
|
}
|
|
|
|
public async Task DeleteLabelAsync(Guid id)
|
|
{
|
|
var label = await _context.KanbanLabels.FindAsync(id)
|
|
?? throw new InvalidOperationException("Label nicht gefunden.");
|
|
|
|
label.IsActive = false;
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Tasks
|
|
|
|
public async Task<List<KanbanTask>> GetTasksAsync(bool includeArchived = false)
|
|
{
|
|
var query = _context.KanbanTasks
|
|
.AsSplitQuery() // Prevents cartesian explosion with multiple includes
|
|
.Include(t => t.Column)
|
|
.Include(t => t.TaskLabels).ThenInclude(tl => tl.Label)
|
|
.Include(t => t.Assignees).ThenInclude(a => a.User)
|
|
.Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer)
|
|
.Include(t => t.Todos)
|
|
.AsQueryable();
|
|
|
|
if (!includeArchived)
|
|
{
|
|
query = query.Where(t => !t.IsArchived);
|
|
}
|
|
|
|
return await query
|
|
.OrderBy(t => t.Column!.SortOrder)
|
|
.ThenBy(t => t.SortOrder)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<List<KanbanTask>> GetTasksByColumnAsync(Guid columnId)
|
|
{
|
|
return await _context.KanbanTasks
|
|
.AsSplitQuery()
|
|
.Include(t => t.TaskLabels).ThenInclude(tl => tl.Label)
|
|
.Include(t => t.Assignees).ThenInclude(a => a.User)
|
|
.Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer)
|
|
.Include(t => t.Todos)
|
|
.Where(t => t.ColumnId == columnId && !t.IsArchived)
|
|
.OrderBy(t => t.SortOrder)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<KanbanTask?> GetTaskByIdAsync(Guid id)
|
|
{
|
|
return await _context.KanbanTasks
|
|
.AsSplitQuery() // Prevents cartesian explosion with multiple includes
|
|
.Include(t => t.Column)
|
|
.Include(t => t.CreatedByUser)
|
|
.Include(t => t.TaskLabels).ThenInclude(tl => tl.Label)
|
|
.Include(t => t.Assignees).ThenInclude(a => a.User)
|
|
.Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer)
|
|
.ThenInclude(c => c.Vehicles)
|
|
.Include(t => t.TaskCustomers).ThenInclude(tc => tc.Vehicle)
|
|
.Include(t => t.TaskTireStacks).ThenInclude(ts => ts.TireStack)
|
|
.ThenInclude(s => s!.Vehicle)
|
|
.Include(t => t.Todos.OrderBy(todo => todo.SortOrder))
|
|
.Include(t => t.Comments.OrderByDescending(c => c.CreatedAt))
|
|
.ThenInclude(c => c.User)
|
|
.Include(t => t.Notes.OrderBy(n => n.SortOrder))
|
|
.Include(t => t.Attachments.OrderByDescending(a => a.CreatedAt))
|
|
.ThenInclude(a => a.UploadedByUser)
|
|
.FirstOrDefaultAsync(t => t.Id == id);
|
|
}
|
|
|
|
public async Task<KanbanTask> CreateTaskAsync(KanbanTask task, Guid? userId = null)
|
|
{
|
|
task.Id = Guid.NewGuid();
|
|
task.CreatedAt = DateTime.UtcNow;
|
|
task.UpdatedAt = DateTime.UtcNow;
|
|
task.CreatedByUserId = userId;
|
|
|
|
// If no column specified, use default
|
|
if (task.ColumnId == Guid.Empty)
|
|
{
|
|
var defaultColumn = await _context.KanbanColumns
|
|
.FirstOrDefaultAsync(c => c.IsDefault && c.IsActive)
|
|
?? await _context.KanbanColumns.FirstOrDefaultAsync(c => c.IsActive);
|
|
|
|
if (defaultColumn == null)
|
|
throw new InvalidOperationException("Keine Kanban-Spalte vorhanden.");
|
|
|
|
task.ColumnId = defaultColumn.Id;
|
|
}
|
|
|
|
// Set sort order to end of column
|
|
var maxOrder = await _context.KanbanTasks
|
|
.Where(t => t.ColumnId == task.ColumnId && !t.IsArchived)
|
|
.MaxAsync(t => (int?)t.SortOrder) ?? -1;
|
|
task.SortOrder = maxOrder + 1;
|
|
|
|
_context.KanbanTasks.Add(task);
|
|
await _context.SaveChangesAsync();
|
|
|
|
// Add activity
|
|
await AddActivityAsync(task.Id, "Aufgabe erstellt", userId);
|
|
|
|
return task;
|
|
}
|
|
|
|
public async Task<KanbanTask> CreateTaskFromTemplateAsync(Guid templateId, string? title = null, Guid? customerId = null, Guid? userId = null)
|
|
{
|
|
var template = await _context.KanbanTaskTemplates
|
|
.Include(t => t.Todos)
|
|
.Include(t => t.TemplateLabels)
|
|
.FirstOrDefaultAsync(t => t.Id == templateId && t.IsActive)
|
|
?? throw new InvalidOperationException("Vorlage nicht gefunden.");
|
|
|
|
// Load customer for placeholder resolution
|
|
Customer? customer = null;
|
|
if (customerId.HasValue)
|
|
{
|
|
customer = await _context.Customers
|
|
.Include(c => c.Vehicles.Where(v => v.IsActive))
|
|
.FirstOrDefaultAsync(c => c.Id == customerId.Value);
|
|
}
|
|
|
|
var resolvedTitle = string.IsNullOrEmpty(title) ? template.TitlePattern : title;
|
|
var resolvedDescription = template.DefaultDescription;
|
|
|
|
if (customer != null)
|
|
{
|
|
resolvedTitle = ResolvePlaceholders(resolvedTitle, customer);
|
|
resolvedDescription = ResolvePlaceholders(resolvedDescription, customer);
|
|
}
|
|
|
|
var task = new KanbanTask
|
|
{
|
|
Title = resolvedTitle,
|
|
Description = resolvedDescription,
|
|
ColumnId = template.DefaultColumnId ?? Guid.Empty,
|
|
Priority = template.DefaultPriority,
|
|
EstimatedMinutes = template.DefaultEstimatedMinutes
|
|
};
|
|
|
|
var createdTask = await CreateTaskAsync(task, userId);
|
|
|
|
// Add todos from template (with placeholder resolution)
|
|
foreach (var templateTodo in template.Todos.OrderBy(t => t.SortOrder))
|
|
{
|
|
var todoText = customer != null ? ResolvePlaceholders(templateTodo.Text, customer) : templateTodo.Text;
|
|
await AddTodoAsync(createdTask.Id, todoText);
|
|
}
|
|
|
|
// Add labels from template
|
|
var labelIds = template.TemplateLabels.Select(tl => tl.LabelId).ToList();
|
|
if (labelIds.Any())
|
|
{
|
|
await SetTaskLabelsAsync(createdTask.Id, labelIds);
|
|
}
|
|
|
|
// Add customer if provided
|
|
if (customerId.HasValue)
|
|
{
|
|
await AddCustomerToTaskAsync(createdTask.Id, customerId.Value);
|
|
}
|
|
|
|
return await GetTaskByIdAsync(createdTask.Id) ?? createdTask;
|
|
}
|
|
|
|
private static string ResolvePlaceholders(string? text, Customer customer)
|
|
{
|
|
if (string.IsNullOrEmpty(text)) return text ?? string.Empty;
|
|
|
|
var vehicle = customer.Vehicles
|
|
.OrderByDescending(v => v.IsPrimary)
|
|
.ThenBy(v => v.CreatedAt)
|
|
.FirstOrDefault();
|
|
|
|
var result = text;
|
|
result = result.Replace("{Kunde}", customer.FullName);
|
|
result = result.Replace("{Vorname}", customer.FirstName ?? "");
|
|
result = result.Replace("{Nachname}", customer.LastName);
|
|
result = result.Replace("{Firma}", customer.Company ?? "");
|
|
result = result.Replace("{Kundennummer}", customer.CustomerNumber);
|
|
result = result.Replace("{Telefon}", customer.Phone ?? "");
|
|
result = result.Replace("{Email}", customer.Email ?? "");
|
|
result = result.Replace("{Fahrzeug}", vehicle?.DisplayName ?? "");
|
|
result = result.Replace("{Reifengröße}", vehicle?.TireSize ?? "");
|
|
result = result.Replace("{Marke}", vehicle?.Make ?? "");
|
|
result = result.Replace("{Modell}", vehicle?.Model ?? "");
|
|
result = result.Replace("{Datum}", DateTime.Now.ToString("dd.MM.yyyy"));
|
|
result = result.Replace("{Jahr}", DateTime.Now.Year.ToString());
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<KanbanTask> UpdateTaskAsync(KanbanTask task, Guid? userId = null)
|
|
{
|
|
var existing = await _context.KanbanTasks.FindAsync(task.Id)
|
|
?? throw new InvalidOperationException("Aufgabe nicht gefunden.");
|
|
|
|
var changes = new List<string>();
|
|
|
|
if (existing.Title != task.Title)
|
|
changes.Add($"Titel geändert zu '{task.Title}'");
|
|
if (existing.Priority != task.Priority)
|
|
changes.Add($"Priorität geändert zu {task.PriorityText}");
|
|
if (existing.DueDate != task.DueDate)
|
|
changes.Add(task.DueDate.HasValue
|
|
? $"Fälligkeitsdatum gesetzt auf {task.DueDate.Value:dd.MM.yyyy}"
|
|
: "Fälligkeitsdatum entfernt");
|
|
|
|
existing.Title = task.Title;
|
|
existing.Description = task.Description;
|
|
existing.DueDate = task.DueDate;
|
|
existing.Priority = task.Priority;
|
|
existing.EstimatedMinutes = task.EstimatedMinutes;
|
|
existing.TireSizeNote = task.TireSizeNote;
|
|
existing.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
// Log activity
|
|
if (changes.Any())
|
|
{
|
|
await AddActivityAsync(task.Id, string.Join(", ", changes), userId);
|
|
}
|
|
|
|
return existing;
|
|
}
|
|
|
|
public async Task MoveTaskAsync(Guid taskId, Guid newColumnId, int newSortOrder, Guid? userId = null)
|
|
{
|
|
var task = await _context.KanbanTasks
|
|
.Include(t => t.Column)
|
|
.FirstOrDefaultAsync(t => t.Id == taskId)
|
|
?? throw new InvalidOperationException("Aufgabe nicht gefunden.");
|
|
|
|
var newColumn = await _context.KanbanColumns.FindAsync(newColumnId)
|
|
?? throw new InvalidOperationException("Spalte nicht gefunden.");
|
|
|
|
var oldColumnName = task.Column?.Name ?? "Unbekannt";
|
|
var columnChanged = task.ColumnId != newColumnId;
|
|
|
|
task.ColumnId = newColumnId;
|
|
task.SortOrder = newSortOrder;
|
|
task.UpdatedAt = DateTime.UtcNow;
|
|
|
|
// Track completion
|
|
if (newColumn.IsCompletedColumn && !task.CompletedAt.HasValue)
|
|
{
|
|
task.CompletedAt = DateTime.UtcNow;
|
|
}
|
|
else if (!newColumn.IsCompletedColumn && task.CompletedAt.HasValue)
|
|
{
|
|
task.CompletedAt = null;
|
|
}
|
|
|
|
// Reorder other tasks in the new column
|
|
var tasksInColumn = await _context.KanbanTasks
|
|
.Where(t => t.ColumnId == newColumnId && t.Id != taskId && !t.IsArchived)
|
|
.OrderBy(t => t.SortOrder)
|
|
.ToListAsync();
|
|
|
|
int order = 0;
|
|
foreach (var t in tasksInColumn)
|
|
{
|
|
if (order == newSortOrder) order++;
|
|
t.SortOrder = order++;
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
if (columnChanged)
|
|
{
|
|
await AddActivityAsync(taskId, $"Verschoben von '{oldColumnName}' nach '{newColumn.Name}'", userId);
|
|
}
|
|
}
|
|
|
|
public async Task DeleteTaskAsync(Guid id)
|
|
{
|
|
var task = await _context.KanbanTasks.FindAsync(id)
|
|
?? throw new InvalidOperationException("Aufgabe nicht gefunden.");
|
|
|
|
task.IsArchived = true;
|
|
task.ArchivedAt = DateTime.UtcNow;
|
|
task.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task ArchiveTaskAsync(Guid id)
|
|
{
|
|
var task = await _context.KanbanTasks.FindAsync(id)
|
|
?? throw new InvalidOperationException("Aufgabe nicht gefunden.");
|
|
|
|
task.IsArchived = true;
|
|
task.ArchivedAt = DateTime.UtcNow;
|
|
task.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task UnarchiveTaskAsync(Guid id)
|
|
{
|
|
var task = await _context.KanbanTasks
|
|
.Include(t => t.Column)
|
|
.FirstOrDefaultAsync(t => t.Id == id)
|
|
?? throw new InvalidOperationException("Aufgabe nicht gefunden.");
|
|
|
|
task.IsArchived = false;
|
|
task.ArchivedAt = null;
|
|
task.UpdatedAt = DateTime.UtcNow;
|
|
|
|
// If the task was in a completed column, keep it there; otherwise move to default
|
|
if (task.Column == null || !task.Column.IsActive)
|
|
{
|
|
var defaultColumn = await _context.KanbanColumns
|
|
.FirstOrDefaultAsync(c => c.IsDefault && c.IsActive)
|
|
?? await _context.KanbanColumns.FirstOrDefaultAsync(c => c.IsActive);
|
|
|
|
if (defaultColumn != null)
|
|
task.ColumnId = defaultColumn.Id;
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
await AddActivityAsync(id, "Aufgabe wiederhergestellt", null);
|
|
}
|
|
|
|
public async Task<List<KanbanTask>> GetRecentlyArchivedTasksAsync(int days = 7)
|
|
{
|
|
var cutoffDate = DateTime.UtcNow.AddDays(-days);
|
|
|
|
return await _context.KanbanTasks
|
|
.AsSplitQuery()
|
|
.Include(t => t.Column)
|
|
.Include(t => t.TaskLabels).ThenInclude(tl => tl.Label)
|
|
.Include(t => t.Assignees).ThenInclude(a => a.User)
|
|
.Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer)
|
|
.Include(t => t.Todos)
|
|
.Where(t => t.IsArchived && t.ArchivedAt.HasValue && t.ArchivedAt.Value >= cutoffDate)
|
|
.OrderByDescending(t => t.ArchivedAt)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task ArchiveOldCompletedTasksAsync(int daysAfterCompletion = 30)
|
|
{
|
|
var cutoffDate = DateTime.UtcNow.AddDays(-daysAfterCompletion);
|
|
|
|
await _context.KanbanTasks
|
|
.Where(t => t.CompletedAt.HasValue
|
|
&& t.CompletedAt.Value < cutoffDate
|
|
&& !t.IsArchived)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.IsArchived, true)
|
|
.SetProperty(t => t.ArchivedAt, DateTime.UtcNow)
|
|
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow));
|
|
|
|
_logger.LogInformation("Alte erledigte Aufgaben archiviert (älter als {Days} Tage).", daysAfterCompletion);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Task Todos
|
|
|
|
public async Task<KanbanTaskTodo> AddTodoAsync(Guid taskId, string text)
|
|
{
|
|
var maxOrder = await _context.KanbanTaskTodos
|
|
.Where(t => t.TaskId == taskId)
|
|
.MaxAsync(t => (int?)t.SortOrder) ?? -1;
|
|
|
|
var todo = new KanbanTaskTodo
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TaskId = taskId,
|
|
Text = text,
|
|
SortOrder = maxOrder + 1,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_context.KanbanTaskTodos.Add(todo);
|
|
await _context.SaveChangesAsync();
|
|
return todo;
|
|
}
|
|
|
|
public async Task<KanbanTaskTodo> UpdateTodoAsync(KanbanTaskTodo todo)
|
|
{
|
|
var existing = await _context.KanbanTaskTodos.FindAsync(todo.Id)
|
|
?? throw new InvalidOperationException("Todo nicht gefunden.");
|
|
|
|
existing.Text = todo.Text;
|
|
await _context.SaveChangesAsync();
|
|
return existing;
|
|
}
|
|
|
|
public async Task ToggleTodoAsync(Guid todoId)
|
|
{
|
|
var todo = await _context.KanbanTaskTodos.FindAsync(todoId)
|
|
?? throw new InvalidOperationException("Todo nicht gefunden.");
|
|
|
|
todo.IsCompleted = !todo.IsCompleted;
|
|
todo.CompletedAt = todo.IsCompleted ? DateTime.UtcNow : null;
|
|
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task DeleteTodoAsync(Guid todoId)
|
|
{
|
|
var todo = await _context.KanbanTaskTodos.FindAsync(todoId);
|
|
if (todo != null)
|
|
{
|
|
_context.KanbanTaskTodos.Remove(todo);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task ReorderTodosAsync(Guid taskId, List<Guid> todoIds)
|
|
{
|
|
for (int i = 0; i < todoIds.Count; i++)
|
|
{
|
|
await _context.KanbanTaskTodos
|
|
.Where(t => t.Id == todoIds[i] && t.TaskId == taskId)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.SortOrder, i));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Comments/Chat
|
|
|
|
public async Task<List<KanbanTaskComment>> GetCommentsAsync(Guid taskId)
|
|
{
|
|
return await _context.KanbanTaskComments
|
|
.Include(c => c.User)
|
|
.Where(c => c.TaskId == taskId)
|
|
.OrderByDescending(c => c.CreatedAt)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<KanbanTaskComment> AddCommentAsync(Guid taskId, string content, Guid? userId)
|
|
{
|
|
var comment = new KanbanTaskComment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TaskId = taskId,
|
|
UserId = userId,
|
|
Content = content,
|
|
CommentType = KanbanCommentType.Chat,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_context.KanbanTaskComments.Add(comment);
|
|
await _context.SaveChangesAsync();
|
|
|
|
return await _context.KanbanTaskComments
|
|
.Include(c => c.User)
|
|
.FirstAsync(c => c.Id == comment.Id);
|
|
}
|
|
|
|
public async Task AddActivityAsync(Guid taskId, string activity, Guid? userId = null)
|
|
{
|
|
var comment = new KanbanTaskComment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TaskId = taskId,
|
|
UserId = userId,
|
|
Content = activity,
|
|
CommentType = KanbanCommentType.Activity,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_context.KanbanTaskComments.Add(comment);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Notes
|
|
|
|
public async Task<List<KanbanTaskNote>> GetNotesAsync(Guid taskId)
|
|
{
|
|
return await _context.KanbanTaskNotes
|
|
.Include(n => n.LastEditedByUser)
|
|
.Where(n => n.TaskId == taskId)
|
|
.OrderBy(n => n.SortOrder)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<KanbanTaskNote> AddNoteAsync(Guid taskId, string title, string content)
|
|
{
|
|
var maxOrder = await _context.KanbanTaskNotes
|
|
.Where(n => n.TaskId == taskId)
|
|
.MaxAsync(n => (int?)n.SortOrder) ?? -1;
|
|
|
|
var note = new KanbanTaskNote
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TaskId = taskId,
|
|
Title = title,
|
|
Content = content,
|
|
SortOrder = maxOrder + 1,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_context.KanbanTaskNotes.Add(note);
|
|
await _context.SaveChangesAsync();
|
|
return note;
|
|
}
|
|
|
|
public async Task<KanbanTaskNote> UpdateNoteAsync(KanbanTaskNote note, Guid? userId = null)
|
|
{
|
|
var existing = await _context.KanbanTaskNotes.FindAsync(note.Id)
|
|
?? throw new InvalidOperationException("Notiz nicht gefunden.");
|
|
|
|
existing.Title = note.Title;
|
|
existing.Content = note.Content;
|
|
existing.LastEditedByUserId = userId;
|
|
existing.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
return existing;
|
|
}
|
|
|
|
public async Task DeleteNoteAsync(Guid noteId)
|
|
{
|
|
var note = await _context.KanbanTaskNotes.FindAsync(noteId);
|
|
if (note != null)
|
|
{
|
|
_context.KanbanTaskNotes.Remove(note);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Attachments
|
|
|
|
public async Task<List<KanbanTaskAttachment>> GetAttachmentsAsync(Guid taskId)
|
|
{
|
|
return await _context.KanbanTaskAttachments
|
|
.Include(a => a.UploadedByUser)
|
|
.Where(a => a.TaskId == taskId)
|
|
.OrderByDescending(a => a.CreatedAt)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<KanbanTaskAttachment?> GetAttachmentByIdAsync(Guid id)
|
|
{
|
|
return await _context.KanbanTaskAttachments.FindAsync(id);
|
|
}
|
|
|
|
public async Task<KanbanTaskAttachment> AddAttachmentAsync(Guid taskId, string fileName, string contentType, byte[] content, Guid? userId)
|
|
{
|
|
if (content.Length > KanbanTaskAttachment.MaxFileSize)
|
|
{
|
|
throw new InvalidOperationException($"Datei ist zu groß. Maximum: {KanbanTaskAttachment.MaxFileSize / (1024 * 1024)} MB");
|
|
}
|
|
|
|
string? filePath = null;
|
|
var storageSettings = await _fileStorageService.GetSettingsAsync();
|
|
|
|
// Try to save to file storage if configured
|
|
if (storageSettings.IsEnabled && storageSettings.IsPathAccessible())
|
|
{
|
|
try
|
|
{
|
|
filePath = await _fileStorageService.SaveFileAsync(content, fileName, "kanban-attachments");
|
|
_logger.LogInformation("Attachment saved to file storage: {FilePath}", filePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to save attachment to file storage, falling back to database");
|
|
filePath = null;
|
|
}
|
|
}
|
|
|
|
var attachment = new KanbanTaskAttachment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TaskId = taskId,
|
|
FileName = fileName,
|
|
ContentType = contentType,
|
|
FileSize = content.Length,
|
|
FilePath = filePath,
|
|
#pragma warning disable CS0618 // Type or member is obsolete
|
|
FileContent = filePath == null ? content : null, // Only store in DB if file storage failed
|
|
#pragma warning restore CS0618
|
|
UploadedByUserId = userId,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_context.KanbanTaskAttachments.Add(attachment);
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
catch
|
|
{
|
|
// Clean up orphaned file if DB save failed
|
|
if (filePath != null)
|
|
{
|
|
try { await _fileStorageService.DeleteFileAsync(filePath); }
|
|
catch (Exception cleanupEx) { _logger.LogWarning(cleanupEx, "Failed to clean up orphaned attachment file: {FilePath}", filePath); }
|
|
}
|
|
throw;
|
|
}
|
|
|
|
await AddActivityAsync(taskId, $"Datei '{fileName}' hinzugefügt", userId);
|
|
|
|
return attachment;
|
|
}
|
|
|
|
public async Task<byte[]?> GetAttachmentContentAsync(Guid attachmentId)
|
|
{
|
|
var attachment = await _context.KanbanTaskAttachments.FindAsync(attachmentId);
|
|
if (attachment == null)
|
|
return null;
|
|
|
|
// Try file storage first
|
|
if (!string.IsNullOrEmpty(attachment.FilePath))
|
|
{
|
|
var content = await _fileStorageService.ReadFileAsync(attachment.FilePath);
|
|
if (content != null)
|
|
return content;
|
|
}
|
|
|
|
// Fall back to database content (legacy)
|
|
#pragma warning disable CS0618
|
|
return attachment.FileContent;
|
|
#pragma warning restore CS0618
|
|
}
|
|
|
|
public async Task DeleteAttachmentAsync(Guid attachmentId)
|
|
{
|
|
var attachment = await _context.KanbanTaskAttachments.FindAsync(attachmentId);
|
|
if (attachment != null)
|
|
{
|
|
var taskId = attachment.TaskId;
|
|
var fileName = attachment.FileName;
|
|
|
|
// Delete from file storage if applicable
|
|
if (!string.IsNullOrEmpty(attachment.FilePath))
|
|
{
|
|
try
|
|
{
|
|
await _fileStorageService.DeleteFileAsync(attachment.FilePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to delete attachment file: {FilePath}", attachment.FilePath);
|
|
}
|
|
}
|
|
|
|
_context.KanbanTaskAttachments.Remove(attachment);
|
|
await _context.SaveChangesAsync();
|
|
|
|
await AddActivityAsync(taskId, $"Datei '{fileName}' entfernt");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Task Labels
|
|
|
|
public async Task AddLabelToTaskAsync(Guid taskId, Guid labelId)
|
|
{
|
|
var exists = await _context.KanbanTaskLabels
|
|
.AnyAsync(tl => tl.TaskId == taskId && tl.LabelId == labelId);
|
|
|
|
if (!exists)
|
|
{
|
|
_context.KanbanTaskLabels.Add(new KanbanTaskLabel
|
|
{
|
|
TaskId = taskId,
|
|
LabelId = labelId
|
|
});
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task RemoveLabelFromTaskAsync(Guid taskId, Guid labelId)
|
|
{
|
|
await _context.KanbanTaskLabels
|
|
.Where(tl => tl.TaskId == taskId && tl.LabelId == labelId)
|
|
.ExecuteDeleteAsync();
|
|
}
|
|
|
|
public async Task SetTaskLabelsAsync(Guid taskId, List<Guid> labelIds)
|
|
{
|
|
// Remove existing at DB level
|
|
await _context.KanbanTaskLabels
|
|
.Where(tl => tl.TaskId == taskId)
|
|
.ExecuteDeleteAsync();
|
|
|
|
// Detach any locally tracked entities to avoid duplicate key conflicts
|
|
var trackedLabels = _context.ChangeTracker.Entries<KanbanTaskLabel>()
|
|
.Where(e => e.Entity.TaskId == taskId)
|
|
.ToList();
|
|
foreach (var entry in trackedLabels)
|
|
entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
|
|
|
// Add new
|
|
foreach (var labelId in labelIds)
|
|
{
|
|
_context.KanbanTaskLabels.Add(new KanbanTaskLabel
|
|
{
|
|
TaskId = taskId,
|
|
LabelId = labelId
|
|
});
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Task Assignees
|
|
|
|
public async Task AddAssigneeAsync(Guid taskId, Guid userId)
|
|
{
|
|
var exists = await _context.KanbanTaskAssignees
|
|
.AnyAsync(ta => ta.TaskId == taskId && ta.UserId == userId);
|
|
|
|
if (!exists)
|
|
{
|
|
_context.KanbanTaskAssignees.Add(new KanbanTaskAssignee
|
|
{
|
|
TaskId = taskId,
|
|
UserId = userId,
|
|
AssignedAt = DateTime.UtcNow
|
|
});
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task RemoveAssigneeAsync(Guid taskId, Guid userId)
|
|
{
|
|
await _context.KanbanTaskAssignees
|
|
.Where(ta => ta.TaskId == taskId && ta.UserId == userId)
|
|
.ExecuteDeleteAsync();
|
|
}
|
|
|
|
public async Task SetAssigneesAsync(Guid taskId, List<Guid> userIds, Guid? changedByUserId = null)
|
|
{
|
|
var currentAssignees = await _context.KanbanTaskAssignees
|
|
.Where(ta => ta.TaskId == taskId)
|
|
.Select(ta => ta.UserId)
|
|
.ToListAsync();
|
|
|
|
var added = userIds.Except(currentAssignees).ToList();
|
|
var removed = currentAssignees.Except(userIds).ToList();
|
|
|
|
// Remove old at DB level
|
|
await _context.KanbanTaskAssignees
|
|
.Where(ta => ta.TaskId == taskId)
|
|
.ExecuteDeleteAsync();
|
|
|
|
// Detach any locally tracked entities to avoid duplicate key conflicts
|
|
var trackedAssignees = _context.ChangeTracker.Entries<KanbanTaskAssignee>()
|
|
.Where(e => e.Entity.TaskId == taskId)
|
|
.ToList();
|
|
foreach (var entry in trackedAssignees)
|
|
entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
|
|
|
// Add new
|
|
foreach (var userId in userIds)
|
|
{
|
|
_context.KanbanTaskAssignees.Add(new KanbanTaskAssignee
|
|
{
|
|
TaskId = taskId,
|
|
UserId = userId,
|
|
AssignedAt = DateTime.UtcNow
|
|
});
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
// Log activity
|
|
if (added.Any() || removed.Any())
|
|
{
|
|
var users = await _context.Users
|
|
.Where(u => added.Contains(u.Id) || removed.Contains(u.Id))
|
|
.ToDictionaryAsync(u => u.Id, u => u.DisplayName ?? u.Username);
|
|
|
|
var activities = new List<string>();
|
|
if (added.Any())
|
|
{
|
|
var names = added.Select(id => users.GetValueOrDefault(id, "Unbekannt"));
|
|
activities.Add($"Zugewiesen: {string.Join(", ", names)}");
|
|
}
|
|
if (removed.Any())
|
|
{
|
|
var names = removed.Select(id => users.GetValueOrDefault(id, "Unbekannt"));
|
|
activities.Add($"Entfernt: {string.Join(", ", names)}");
|
|
}
|
|
|
|
await AddActivityAsync(taskId, string.Join("; ", activities), changedByUserId);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Task Customers
|
|
|
|
public async Task AddCustomerToTaskAsync(Guid taskId, Guid customerId)
|
|
{
|
|
var exists = await _context.KanbanTaskCustomers
|
|
.AnyAsync(tc => tc.TaskId == taskId && tc.CustomerId == customerId);
|
|
|
|
if (!exists)
|
|
{
|
|
_context.KanbanTaskCustomers.Add(new KanbanTaskCustomer
|
|
{
|
|
TaskId = taskId,
|
|
CustomerId = customerId
|
|
});
|
|
await _context.SaveChangesAsync();
|
|
|
|
var customer = await _context.Customers.FindAsync(customerId);
|
|
if (customer != null)
|
|
{
|
|
await AddActivityAsync(taskId, $"Kunde '{customer.FullName}' verknüpft");
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task RemoveCustomerFromTaskAsync(Guid taskId, Guid customerId)
|
|
{
|
|
var customer = await _context.Customers.FindAsync(customerId);
|
|
|
|
await _context.KanbanTaskCustomers
|
|
.Where(tc => tc.TaskId == taskId && tc.CustomerId == customerId)
|
|
.ExecuteDeleteAsync();
|
|
|
|
// Also remove tire stacks for this customer
|
|
await _context.KanbanTaskTireStacks
|
|
.Where(ts => ts.TaskId == taskId && ts.TireStack!.CustomerId == customerId)
|
|
.ExecuteDeleteAsync();
|
|
|
|
if (customer != null)
|
|
{
|
|
await AddActivityAsync(taskId, $"Kunde '{customer.FullName}' entfernt");
|
|
}
|
|
}
|
|
|
|
public async Task SetTaskCustomersAsync(Guid taskId, Dictionary<Guid, Guid?> customerVehicleMap)
|
|
{
|
|
await _context.KanbanTaskCustomers
|
|
.Where(tc => tc.TaskId == taskId)
|
|
.ExecuteDeleteAsync();
|
|
|
|
// Detach any locally tracked entities to avoid duplicate key conflicts
|
|
var trackedCustomers = _context.ChangeTracker.Entries<KanbanTaskCustomer>()
|
|
.Where(e => e.Entity.TaskId == taskId)
|
|
.ToList();
|
|
foreach (var entry in trackedCustomers)
|
|
entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
|
|
|
foreach (var (customerId, vehicleId) in customerVehicleMap)
|
|
{
|
|
_context.KanbanTaskCustomers.Add(new KanbanTaskCustomer
|
|
{
|
|
TaskId = taskId,
|
|
CustomerId = customerId,
|
|
VehicleId = vehicleId
|
|
});
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Task TireStacks
|
|
|
|
public async Task AddTireStackToTaskAsync(Guid taskId, Guid tireStackId)
|
|
{
|
|
var exists = await _context.KanbanTaskTireStacks
|
|
.AnyAsync(ts => ts.TaskId == taskId && ts.TireStackId == tireStackId);
|
|
|
|
if (!exists)
|
|
{
|
|
_context.KanbanTaskTireStacks.Add(new KanbanTaskTireStack
|
|
{
|
|
TaskId = taskId,
|
|
TireStackId = tireStackId
|
|
});
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task RemoveTireStackFromTaskAsync(Guid taskId, Guid tireStackId)
|
|
{
|
|
await _context.KanbanTaskTireStacks
|
|
.Where(ts => ts.TaskId == taskId && ts.TireStackId == tireStackId)
|
|
.ExecuteDeleteAsync();
|
|
}
|
|
|
|
public async Task SetTaskTireStacksAsync(Guid taskId, List<Guid> tireStackIds)
|
|
{
|
|
await _context.KanbanTaskTireStacks
|
|
.Where(ts => ts.TaskId == taskId)
|
|
.ExecuteDeleteAsync();
|
|
|
|
// Detach any locally tracked entities to avoid duplicate key conflicts
|
|
var trackedTireStacks = _context.ChangeTracker.Entries<KanbanTaskTireStack>()
|
|
.Where(e => e.Entity.TaskId == taskId)
|
|
.ToList();
|
|
foreach (var entry in trackedTireStacks)
|
|
entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
|
|
|
foreach (var tireStackId in tireStackIds)
|
|
{
|
|
_context.KanbanTaskTireStacks.Add(new KanbanTaskTireStack
|
|
{
|
|
TaskId = taskId,
|
|
TireStackId = tireStackId
|
|
});
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Templates
|
|
|
|
public async Task<List<KanbanTaskTemplate>> GetTemplatesAsync()
|
|
{
|
|
return await _context.KanbanTaskTemplates
|
|
.AsSplitQuery() // Prevents cartesian explosion with multiple includes
|
|
.Include(t => t.DefaultColumn)
|
|
.Include(t => t.Todos.OrderBy(todo => todo.SortOrder))
|
|
.Include(t => t.TemplateLabels).ThenInclude(tl => tl.Label)
|
|
.Where(t => t.IsActive)
|
|
.OrderBy(t => t.SortOrder)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<KanbanTaskTemplate?> GetTemplateByIdAsync(Guid id)
|
|
{
|
|
return await _context.KanbanTaskTemplates
|
|
.AsSplitQuery()
|
|
.Include(t => t.DefaultColumn)
|
|
.Include(t => t.Todos.OrderBy(todo => todo.SortOrder))
|
|
.Include(t => t.TemplateLabels).ThenInclude(tl => tl.Label)
|
|
.FirstOrDefaultAsync(t => t.Id == id && t.IsActive);
|
|
}
|
|
|
|
public async Task<KanbanTaskTemplate> CreateTemplateAsync(KanbanTaskTemplate template)
|
|
{
|
|
template.Id = Guid.NewGuid();
|
|
template.IsActive = true;
|
|
template.CreatedAt = DateTime.UtcNow;
|
|
template.UpdatedAt = DateTime.UtcNow;
|
|
|
|
var maxOrder = await _context.KanbanTaskTemplates.MaxAsync(t => (int?)t.SortOrder) ?? -1;
|
|
template.SortOrder = maxOrder + 1;
|
|
|
|
_context.KanbanTaskTemplates.Add(template);
|
|
await _context.SaveChangesAsync();
|
|
return template;
|
|
}
|
|
|
|
public async Task<KanbanTaskTemplate> UpdateTemplateAsync(KanbanTaskTemplate template)
|
|
{
|
|
var existing = await _context.KanbanTaskTemplates.FindAsync(template.Id)
|
|
?? throw new InvalidOperationException("Vorlage nicht gefunden.");
|
|
|
|
existing.Name = template.Name;
|
|
existing.Description = template.Description;
|
|
existing.TitlePattern = template.TitlePattern;
|
|
existing.DefaultDescription = template.DefaultDescription;
|
|
existing.DefaultColumnId = template.DefaultColumnId;
|
|
existing.DefaultPriority = template.DefaultPriority;
|
|
existing.DefaultEstimatedMinutes = template.DefaultEstimatedMinutes;
|
|
existing.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
return existing;
|
|
}
|
|
|
|
public async Task DeleteTemplateAsync(Guid id)
|
|
{
|
|
var template = await _context.KanbanTaskTemplates.FindAsync(id)
|
|
?? throw new InvalidOperationException("Vorlage nicht gefunden.");
|
|
|
|
template.IsActive = false;
|
|
template.UpdatedAt = DateTime.UtcNow;
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task<KanbanTaskTemplateTodo> AddTemplateTodoAsync(Guid templateId, string text)
|
|
{
|
|
var maxOrder = await _context.KanbanTaskTemplateTodos
|
|
.Where(t => t.TemplateId == templateId)
|
|
.MaxAsync(t => (int?)t.SortOrder) ?? -1;
|
|
|
|
var todo = new KanbanTaskTemplateTodo
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TemplateId = templateId,
|
|
Text = text,
|
|
SortOrder = maxOrder + 1
|
|
};
|
|
|
|
_context.KanbanTaskTemplateTodos.Add(todo);
|
|
await _context.SaveChangesAsync();
|
|
return todo;
|
|
}
|
|
|
|
public async Task DeleteTemplateTodoAsync(Guid todoId)
|
|
{
|
|
var todo = await _context.KanbanTaskTemplateTodos.FindAsync(todoId);
|
|
if (todo != null)
|
|
{
|
|
_context.KanbanTaskTemplateTodos.Remove(todo);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task SyncTemplateTodosAsync(Guid templateId, List<KanbanTaskTemplateTodo> todos)
|
|
{
|
|
// Remove all existing todos
|
|
await _context.KanbanTaskTemplateTodos
|
|
.Where(t => t.TemplateId == templateId)
|
|
.ExecuteDeleteAsync();
|
|
|
|
// Add new todos
|
|
for (int i = 0; i < todos.Count; i++)
|
|
{
|
|
_context.KanbanTaskTemplateTodos.Add(new KanbanTaskTemplateTodo
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TemplateId = templateId,
|
|
Text = todos[i].Text,
|
|
SortOrder = i
|
|
});
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task SetTemplateLabelsAsync(Guid templateId, List<Guid> labelIds)
|
|
{
|
|
await _context.KanbanTaskTemplateLabels
|
|
.Where(tl => tl.TemplateId == templateId)
|
|
.ExecuteDeleteAsync();
|
|
|
|
foreach (var labelId in labelIds)
|
|
{
|
|
_context.KanbanTaskTemplateLabels.Add(new KanbanTaskTemplateLabel
|
|
{
|
|
TemplateId = templateId,
|
|
LabelId = labelId
|
|
});
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Statistics
|
|
|
|
public async Task<KanbanStatistics> GetStatisticsAsync()
|
|
{
|
|
var today = DateTime.UtcNow.Date;
|
|
var weekStart = today.AddDays(-(int)today.DayOfWeek);
|
|
|
|
// Get completed column IDs to exclude from "open" counts
|
|
var completedColumnIds = await _context.KanbanColumns
|
|
.Where(c => c.IsCompletedColumn && c.IsActive)
|
|
.Select(c => c.Id)
|
|
.ToListAsync();
|
|
|
|
// Use database-side counting for better performance
|
|
var baseQuery = _context.KanbanTasks.Where(t => !t.IsArchived);
|
|
var openTasksQuery = baseQuery.Where(t => !completedColumnIds.Contains(t.ColumnId));
|
|
|
|
var totalOpenTasks = await openTasksQuery.CountAsync();
|
|
var tasksDueToday = await openTasksQuery.CountAsync(t => t.DueDate.HasValue && t.DueDate.Value.Date == today);
|
|
var overdueTasks = await openTasksQuery.CountAsync(t => t.DueDate.HasValue && t.DueDate.Value.Date < today);
|
|
var completedThisWeek = await baseQuery.CountAsync(t => t.CompletedAt.HasValue && t.CompletedAt.Value >= weekStart);
|
|
|
|
// Get tasks per column with a single grouped query
|
|
var tasksPerColumn = await baseQuery
|
|
.GroupBy(t => t.ColumnId)
|
|
.Select(g => new { ColumnId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.ColumnId, x => x.Count);
|
|
|
|
return new KanbanStatistics
|
|
{
|
|
TotalTasks = totalOpenTasks,
|
|
TasksDueToday = tasksDueToday,
|
|
OverdueTasks = overdueTasks,
|
|
CompletedThisWeek = completedThisWeek,
|
|
TasksPerColumn = tasksPerColumn
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|