using Lagerbestand.Data; using Lagerbestand.Models; using Microsoft.EntityFrameworkCore; namespace Lagerbestand.Services; public class KanbanService : IKanbanService { private readonly AppDbContext _context; private readonly ILogger _logger; private readonly IFileStorageService _fileStorageService; public KanbanService(AppDbContext context, ILogger logger, IFileStorageService fileStorageService) { _context = context; _logger = logger; _fileStorageService = fileStorageService; } #region Columns public async Task> GetColumnsAsync() { return await _context.KanbanColumns .Where(c => c.IsActive) .OrderBy(c => c.SortOrder) .ToListAsync(); } public async Task GetColumnByIdAsync(Guid id) { return await _context.KanbanColumns.FindAsync(id); } public async Task 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 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 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> GetLabelsAsync() { return await _context.KanbanLabels .Where(l => l.IsActive) .OrderBy(l => l.SortOrder) .ToListAsync(); } public async Task GetLabelByIdAsync(Guid id) { return await _context.KanbanLabels.FindAsync(id); } public async Task 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 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> 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> 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 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 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 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 UpdateTaskAsync(KanbanTask task, Guid? userId = null) { var existing = await _context.KanbanTasks.FindAsync(task.Id) ?? throw new InvalidOperationException("Aufgabe nicht gefunden."); var changes = new List(); 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> 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 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 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 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> GetCommentsAsync(Guid taskId) { return await _context.KanbanTaskComments .Include(c => c.User) .Where(c => c.TaskId == taskId) .OrderByDescending(c => c.CreatedAt) .ToListAsync(); } public async Task 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> GetNotesAsync(Guid taskId) { return await _context.KanbanTaskNotes .Include(n => n.LastEditedByUser) .Where(n => n.TaskId == taskId) .OrderBy(n => n.SortOrder) .ToListAsync(); } public async Task 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 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> GetAttachmentsAsync(Guid taskId) { return await _context.KanbanTaskAttachments .Include(a => a.UploadedByUser) .Where(a => a.TaskId == taskId) .OrderByDescending(a => a.CreatedAt) .ToListAsync(); } public async Task GetAttachmentByIdAsync(Guid id) { return await _context.KanbanTaskAttachments.FindAsync(id); } public async Task 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 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 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() .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 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() .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(); 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 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() .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 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() .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> 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 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 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 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 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 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 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 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 }