Files

2399 lines
96 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/dashboard"
@implements IAsyncDisposable
@inject IKanbanService KanbanService
@inject ICustomerService CustomerService
@inject ITireStackService TireStackService
@inject IAuthService AuthService
@inject UserSessionService UserSession
@inject IJSRuntime JS
@using Lagerbestand.Components.Shared
<PageTitle>Dashboard - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Dashboard - Reifen Späth Lagerverwaltung"
Description="Kanban Dashboard für die Aufgabenverwaltung in der Reifen Späth Lagerverwaltung."
Robots="noindex, nofollow" />
@if (!UserSession.IsWorkerOrAdmin)
{
<section class="access-denied" role="alert" aria-labelledby="access-denied-title">
<div class="access-denied-icon" aria-hidden="true">🔒</div>
<h1 id="access-denied-title">Zugriff verweigert</h1>
<p>Diese Seite ist nur für Mitarbeiter und Administratoren zugänglich.</p>
<a href="/" class="btn btn-primary">Zur Startseite</a>
</section>
}
else
{
<div class="dashboard-page">
<header class="page-header">
<div class="header-content">
<h1>Dashboard</h1>
<nav class="header-actions" aria-label="Schnellaktionen">
@if (UserSession.IsAdmin)
{
<button class="btn btn-outline btn-sm" @onclick="ShowLabelsModal" title="Labels verwalten">
<span aria-hidden="true">🏷️</span> Labels
</button>
}
<button class="btn btn-outline" @onclick="ShowTemplateModal">
<span aria-hidden="true">📋</span> Vorlagen
</button>
<button class="btn btn-primary" @onclick="() => OpenCreateTaskModal(null)">
<span aria-hidden="true">+</span> Neue Aufgabe
</button>
</nav>
</div>
</header>
@* Error Display *@
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger" role="alert">
<span>@errorMessage</span>
<button type="button" class="alert-close" @onclick="() => errorMessage = null">×</button>
</div>
}
@* Statistics *@
<section class="stats-grid" aria-label="Aufgabenstatistik">
<article class="stat-card">
<span class="stat-value">@statistics.TotalTasks</span>
<span class="stat-label">Offene Aufgaben</span>
</article>
<article class="stat-card @(statistics.TasksDueToday > 0 ? "warning" : "")">
<span class="stat-value">@statistics.TasksDueToday</span>
<span class="stat-label"><span aria-hidden="true">📅</span> Heute fällig</span>
</article>
<article class="stat-card @(statistics.OverdueTasks > 0 ? "danger" : "")">
<span class="stat-value">@statistics.OverdueTasks</span>
<span class="stat-label"><span aria-hidden="true">⚠️</span> Überfällig</span>
</article>
<article class="stat-card success">
<span class="stat-value">@statistics.CompletedThisWeek</span>
<span class="stat-label"><span aria-hidden="true">✅</span> Diese Woche erledigt</span>
</article>
</section>
@* Search & Filter Bar *@
<div class="board-toolbar">
<div class="search-box">
<span class="search-icon" aria-hidden="true">🔍</span>
<input type="text" class="search-input" placeholder="Aufgaben durchsuchen..."
@bind="boardSearchText" @bind:event="oninput" />
@if (!string.IsNullOrWhiteSpace(boardSearchText))
{
<button class="search-clear" @onclick="() => boardSearchText = string.Empty" aria-label="Suche leeren">×</button>
}
</div>
<div class="filter-actions">
<div class="filter-dropdown-wrapper">
<button class="btn btn-outline btn-sm filter-btn @(filterLabelId.HasValue ? "active" : "")" @onclick="ToggleLabelFilter">
<span aria-hidden="true">🏷️</span> Label
@if (filterLabelId.HasValue)
{
var activeLabel = labels.FirstOrDefault(l => l.Id == filterLabelId.Value);
if (activeLabel != null)
{
<span class="filter-badge" style="background-color: @activeLabel.Color"></span>
}
}
</button>
@if (showLabelFilter)
{
<div class="filter-dropdown" @onclick:stopPropagation>
<div class="filter-search-box">
<input type="text" class="filter-search-input" placeholder="Label suchen..."
@bind="filterLabelSearch" @bind:event="oninput" />
</div>
<button class="filter-dropdown-item @(filterLabelId == null ? "selected" : "")" @onclick="() => SelectLabelFilter(null)">
Alle Labels
</button>
@foreach (var label in GetFilteredLabels())
{
<button class="filter-dropdown-item @(filterLabelId == label.Id ? "selected" : "")" @onclick="() => SelectLabelFilter(label.Id)">
<span class="filter-label-dot" style="background-color: @label.Color"></span>
@label.Name
</button>
}
</div>
}
</div>
<div class="filter-dropdown-wrapper">
<button class="btn btn-outline btn-sm filter-btn @(filterCustomerId.HasValue ? "active" : "")" @onclick="ToggleCustomerFilter">
<span aria-hidden="true">👤</span> Kunde
@if (filterCustomerId.HasValue)
{
var activeCust = customers.FirstOrDefault(c => c.Id == filterCustomerId.Value);
if (activeCust != null)
{
<span class="filter-active-text">@activeCust.LastName</span>
}
}
</button>
@if (showCustomerFilter)
{
<div class="filter-dropdown filter-dropdown-customers" @onclick:stopPropagation>
<div class="filter-search-box">
<input type="text" class="filter-search-input" placeholder="Kunde suchen..."
@bind="filterCustomerSearch" @bind:event="oninput" />
</div>
<button class="filter-dropdown-item @(filterCustomerId == null ? "selected" : "")" @onclick="() => SelectCustomerFilter(null)">
Alle Kunden
</button>
@foreach (var customer in GetFilteredFilterCustomers())
{
<button class="filter-dropdown-item @(filterCustomerId == customer.Id ? "selected" : "")" @onclick="() => SelectCustomerFilter(customer.Id)">
@customer.FullName
@if (!string.IsNullOrEmpty(customer.CustomerNumber))
{
<span class="filter-customer-number">@customer.CustomerNumber</span>
}
</button>
}
</div>
}
</div>
@if (HasActiveFilters)
{
<button class="btn btn-outline btn-sm filter-clear-btn" @onclick="ClearAllFilters" title="Alle Filter zurücksetzen">
✕ Filter
</button>
}
</div>
</div>
@if (isLoading)
{
<div class="loading-state" role="status" aria-live="polite">
<div class="spinner" aria-hidden="true"></div>
<span>Lade Dashboard...</span>
</div>
}
else if (columns.Count == 0)
{
<div class="empty-state">
<div class="empty-icon" aria-hidden="true">📋</div>
<h2>Keine Spalten vorhanden</h2>
<p>Das Kanban-Board wurde noch nicht initialisiert.</p>
@if (UserSession.IsAdmin)
{
<button class="btn btn-primary" @onclick="InitializeDefaultColumns">Standardspalten erstellen</button>
}
</div>
}
else
{
@* Kanban Board *@
<div class="kanban-board" role="region" aria-label="Kanban Board">
@foreach (var column in columns)
{
var columnTasks = GetTasksForColumn(column);
<div class="kanban-column @(dragOverColumnId == column.Id ? "drag-over" : "")"
data-column-id="@column.Id"
@ondrop="() => HandleDrop(column.Id)"
@ondragover="HandleDragOver"
@ondragover:preventDefault
@ondragenter="() => HandleDragEnter(column.Id)"
@ondragleave="HandleDragLeave">
<div class="column-header" style="@GetColumnHeaderStyle(column)">
<div class="column-title">
@if (!string.IsNullOrEmpty(column.Icon))
{
<span class="column-icon" aria-hidden="true">@column.Icon</span>
}
<h2>@column.Name</h2>
<span class="task-count">@columnTasks.Count</span>
</div>
<button class="column-add-btn"
@onclick="() => OpenCreateTaskModal(column.Id)"
title="Aufgabe in @column.Name hinzufügen"
aria-label="Aufgabe in @column.Name hinzufügen">
<span aria-hidden="true">+</span>
</button>
</div>
<div class="column-tasks">
@foreach (var task in columnTasks)
{
<div @key="task.Id">
@RenderTaskCard(task)
</div>
}
</div>
</div>
}
</div>
}
</div>
@* Create/Edit Task Modal *@
@if (showTaskModal && editingTask != null)
{
<div class="modal-backdrop" @onclick="CloseTaskModal"></div>
<div class="modal task-modal" role="dialog" aria-labelledby="task-modal-title" aria-modal="true">
<div class="modal-header">
<h2 id="task-modal-title">
@if (isEditingExistingTask)
{
@:Aufgabe bearbeiten
}
else if (activeTemplate != null)
{
@:Neue Aufgabe — @activeTemplate.Name
}
else
{
@:Neue Aufgabe
}
</h2>
<button type="button" class="modal-close" @onclick="CloseTaskModal" aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<EditForm Model="editingTask" OnValidSubmit="SaveTask">
<DataAnnotationsValidator />
<div class="form-group">
<label for="task-title">Titel *</label>
<InputText id="task-title" class="form-control" @bind-Value="editingTask.Title" placeholder="Aufgabentitel..." />
<ValidationMessage For="() => editingTask.Title" />
</div>
<div class="form-group">
<label for="task-description">Beschreibung</label>
<InputTextArea id="task-description" class="form-control" @bind-Value="editingTask.Description" rows="3" placeholder="Beschreibung..." />
</div>
<div class="form-row">
<div class="form-group">
<label for="task-column">Spalte</label>
<InputSelect id="task-column" class="form-control" @bind-Value="editingTask.ColumnId">
@foreach (var col in columns)
{
<option value="@col.Id">@col.Name</option>
}
</InputSelect>
</div>
<div class="form-group">
<label for="task-priority">Priorität</label>
<InputSelect id="task-priority" class="form-control" @bind-Value="editingTask.Priority">
<option value="@KanbanTaskPriority.Low">⬇️ Niedrig</option>
<option value="@KanbanTaskPriority.Medium">➡️ Mittel</option>
<option value="@KanbanTaskPriority.High">⬆️ Hoch</option>
<option value="@KanbanTaskPriority.Urgent">🔥 Dringend</option>
</InputSelect>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="task-duedate">Fälligkeitsdatum</label>
<InputDate id="task-duedate" class="form-control" @bind-Value="editingTask.DueDate" />
</div>
<div class="form-group">
<label for="task-estimated">Geschätzte Zeit (Min.)</label>
<InputNumber id="task-estimated" class="form-control" @bind-Value="editingTask.EstimatedMinutes" min="0" />
</div>
</div>
<div class="form-group">
<label for="task-tiresize">Reifengröße</label>
<InputText id="task-tiresize" class="form-control" @bind-Value="editingTask.TireSizeNote" placeholder="z.B. 205/55 R16" />
</div>
@* Labels *@
@if (labels.Count > 0)
{
<div class="form-group">
<label>Labels</label>
<div class="label-selector">
@foreach (var label in labels)
{
<label @key="label.Id" class="label-checkbox" style="--label-color: @label.Color">
<input type="checkbox"
checked="@selectedLabelIds.Contains(label.Id)"
@onchange="(e) => ToggleLabel(label.Id, (bool)(e.Value ?? false))" />
<span class="label-tag">@label.Name</span>
</label>
}
</div>
</div>
}
@* Assignees *@
@if (availableUsers.Count > 0)
{
<div class="form-group">
<label>Zugewiesen an</label>
<div class="assignee-selector">
@foreach (var user in availableUsers)
{
<label class="assignee-checkbox">
<input type="checkbox"
checked="@selectedAssigneeIds.Contains(user.Id)"
@onchange="(e) => ToggleAssignee(user.Id, (bool)(e.Value ?? false))" />
<span class="assignee-name">@(user.DisplayName ?? user.Username)</span>
</label>
}
</div>
</div>
}
@* Customer Link *@
<div class="form-group">
<label for="task-customer">Kunde verknüpfen</label>
@if (customers.Count > 0)
{
<div class="customer-search-wrapper">
<input type="text" class="form-control" placeholder="Kunde suchen..."
@bind="customerSearchText" @bind:event="oninput" />
<div class="customer-search-results">
@foreach (var customer in GetFilteredCustomers())
{
<button type="button" class="customer-search-item" @onclick="() => SelectCustomerFromSearch(customer.Id)">
<span class="customer-search-name">@customer.FullName</span>
@if (!string.IsNullOrEmpty(customer.CustomerNumber))
{
<span class="customer-search-number">@customer.CustomerNumber</span>
}
</button>
}
@if (!GetFilteredCustomers().Any())
{
<div class="customer-search-empty">Keine Kunden gefunden</div>
}
</div>
</div>
}
else
{
<p class="text-muted">Keine Kunden vorhanden. <a href="/customers">Kunden anlegen</a></p>
}
@if (selectedCustomerIds.Count > 0)
{
<div class="selected-customers">
@foreach (var customerId in selectedCustomerIds.ToList())
{
var customer = customers.FirstOrDefault(c => c.Id == customerId);
if (customer != null)
{
var activeVehicles = customer.Vehicles?.Where(v => v.IsActive).ToList() ?? new();
var cid = customerId;
<div class="customer-tag-row">
<span class="customer-tag">
@customer.FullName
@if (activeVehicles.Count == 1)
{
<span class="customer-vehicle-badge">@activeVehicles[0].ShortName</span>
}
<button type="button" class="remove-btn" @onclick="() => RemoveCustomer(cid)">×</button>
</span>
@if (activeVehicles.Count >= 2)
{
<select class="vehicle-select form-control"
value="@(selectedVehicleIds.TryGetValue(cid, out var vid) ? vid?.ToString() ?? "" : "")"
@onchange="(e) => OnVehicleSelected(cid, string.IsNullOrEmpty(e.Value?.ToString()) ? null : Guid.Parse(e.Value!.ToString()!))">
<option value="">Fahrzeug wählen (optional)</option>
@foreach (var vehicle in activeVehicles)
{
<option value="@vehicle.Id">@vehicle.DisplayName</option>
}
</select>
}
</div>
}
}
</div>
}
</div>
@* Tire Stacks (only show if customers are selected and have tire stacks) *@
@if (availableTireStacks.Count > 0)
{
<div class="form-group">
<label>Reifenstapel verknüpfen</label>
<div class="tirestack-selector">
@foreach (var stack in availableTireStacks)
{
<label class="tirestack-checkbox">
<input type="checkbox"
checked="@selectedTireStackIds.Contains(stack.Id)"
@onchange="(e) => ToggleTireStack(stack.Id, (bool)(e.Value ?? false))" />
<span class="tirestack-info">
@GetSeasonIcon(stack.Season) @(stack.Vehicle?.ShortName ?? "Ohne KFZ") - @(stack.TireSize ?? "?")
<small>(@(stack.StorageLocation?.Code ?? "-"))</small>
</span>
</label>
}
</div>
</div>
}
@* Template Todos Preview *@
@if (activeTemplate != null && pendingTemplateTodos.Count > 0)
{
<div class="form-group">
<label>Todos aus Vorlage (@pendingTemplateTodos.Count)</label>
<div class="template-todos-preview">
@foreach (var todoText in pendingTemplateTodos)
{
<div class="template-todo-preview-item">
<span class="todo-check" aria-hidden="true">☐</span>
<span>@todoText</span>
</div>
}
</div>
</div>
}
<div class="modal-footer">
<button type="button" class="btn btn-outline" @onclick="CloseTaskModal" disabled="@isSaving">Abbrechen</button>
<button type="submit" class="btn btn-primary" disabled="@isSaving">
@if (isSaving)
{
<span class="spinner-sm" aria-hidden="true"></span>
}
@(isEditingExistingTask ? "Speichern" : "Erstellen")
</button>
</div>
</EditForm>
</div>
</div>
}
@* Task Detail Modal *@
@if (showTaskDetailModal && selectedTask != null)
{
<div class="modal-backdrop" @onclick="CloseTaskDetailModal"></div>
<div class="modal task-detail-modal large" role="dialog" aria-labelledby="task-detail-title" aria-modal="true">
<div class="modal-header">
<div class="task-detail-header-content">
<h2 id="task-detail-title">@selectedTask.Title</h2>
<div class="task-meta">
@if (selectedTask.IsArchived)
{
<span class="priority-badge archived-indicator">📦 Archiviert</span>
}
<span class="priority-badge priority-@selectedTask.Priority.ToString().ToLower()">
@GetPriorityIcon(selectedTask.Priority) @GetPriorityText(selectedTask.Priority)
</span>
@if (selectedTask.DueDate.HasValue)
{
<span class="due-date @(selectedTask.IsOverdue ? "overdue" : selectedTask.IsDueToday ? "due-today" : "")">
📅 @selectedTask.DueDate.Value.ToLocalTime().ToString("dd.MM.yyyy")
</span>
}
</div>
</div>
<div class="modal-header-actions">
<button type="button" class="modal-close" @onclick="CloseTaskDetailModal" aria-label="Schließen">×</button>
</div>
</div>
<div class="modal-body task-detail-body">
<div class="task-detail-main">
@* Description *@
<section class="detail-section">
<h3>Beschreibung</h3>
@if (string.IsNullOrWhiteSpace(selectedTask.Description))
{
<p class="text-muted">Keine Beschreibung vorhanden.</p>
}
else
{
<div class="description-content">@selectedTask.Description</div>
}
</section>
@* Tire Size Note - always visible and editable *@
<section class="detail-section">
<h3>🛞 Reifengröße</h3>
<input type="text" class="form-control tire-size-input"
@bind="taskTireSizeNote"
@bind:after="SaveTaskTireSizeNote"
placeholder="z.B. 205/55 R16" />
</section>
@* Labels *@
@if (selectedTask.TaskLabels?.Any() == true)
{
<section class="detail-section">
<h3>Labels</h3>
<div class="label-list">
@foreach (var tl in selectedTask.TaskLabels)
{
@if (tl.Label != null)
{
<span class="label-tag" style="background-color: @tl.Label.Color">@tl.Label.Name</span>
}
}
</div>
</section>
}
@* Assignees *@
@if (selectedTask.Assignees?.Any() == true)
{
<section class="detail-section">
<h3>Zugewiesen an</h3>
<div class="assignee-list">
@foreach (var a in selectedTask.Assignees)
{
<span class="assignee-badge">
<span class="assignee-avatar">@GetUserInitial(a.User)</span>
@(a.User?.DisplayName ?? a.User?.Username ?? "?")
</span>
}
</div>
</section>
}
@* Linked Customers *@
@if (selectedTask.TaskCustomers?.Any() == true)
{
<section class="detail-section">
<h3>Verknüpfte Kunden</h3>
<div class="customer-list">
@foreach (var tc in selectedTask.TaskCustomers)
{
@if (tc.Customer != null)
{
<div class="customer-card">
<span class="customer-name">👤 @tc.Customer.FullName</span>
@if (tc.Customer.Vehicles?.Any() == true)
{
<div class="customer-vehicles">
@foreach (var v in tc.Customer.Vehicles)
{
<span class="vehicle-badge">🚗 @v.ShortName</span>
}
</div>
}
</div>
}
}
</div>
</section>
}
@* Linked Tire Stacks *@
@if (selectedTask.TaskTireStacks?.Any() == true)
{
<section class="detail-section">
<h3>Verknüpfte Reifenstapel</h3>
<div class="tirestack-list">
@foreach (var ts in selectedTask.TaskTireStacks)
{
@if (ts.TireStack != null)
{
<div class="tirestack-card">
<span class="season-icon">@GetSeasonIcon(ts.TireStack.Season)</span>
<div class="tirestack-info">
<span class="vehicle">@(ts.TireStack.Vehicle?.ShortName ?? "Ohne KFZ")</span>
<span class="details">@(ts.TireStack.TireSize ?? "?") | @(ts.TireStack.StorageLocation?.Code ?? "-")</span>
<TireInspectionPanel TireStackId="@ts.TireStack.Id" AllowEdit="true" Compact="true"
ScannerSubtitle="@ts.TireStack.TicketNumber" />
</div>
</div>
}
}
</div>
</section>
}
@* Todos *@
<section class="detail-section">
<h3>Checkliste @(selectedTask.TodosTotal > 0 ? $"({selectedTask.TodosCompleted}/{selectedTask.TodosTotal})" : "")</h3>
<div class="todo-list">
@if (selectedTask.Todos != null)
{
@foreach (var todo in selectedTask.Todos.OrderBy(t => t.SortOrder))
{
<div class="todo-item @(todo.IsCompleted ? "completed" : "")">
<input type="checkbox"
checked="@todo.IsCompleted"
@onchange="() => ToggleTodoItem(todo.Id)" />
@if (editingTodoId == todo.Id)
{
<input type="text" class="todo-edit-input"
@bind="editingTodoText"
@onblur="SaveTodoEdit"
@onkeydown="HandleTodoKeyDown" />
}
else
{
<span class="todo-text" @ondblclick="() => StartEditTodo(todo)">@todo.Text</span>
}
<button type="button" class="todo-delete" @onclick="() => DeleteTodo(todo.Id)" title="Löschen">×</button>
</div>
}
}
<div class="todo-add">
<input type="text" class="form-control" @bind="newTodoText"
placeholder="Neues Element hinzufügen..."
@onkeydown="HandleNewTodoKeyDown" />
<button type="button" class="btn btn-sm btn-primary" @onclick="AddTodo"
disabled="@string.IsNullOrWhiteSpace(newTodoText)">+</button>
</div>
</div>
</section>
@* Attachments *@
<section class="detail-section">
<h3>Anhänge (@(selectedTask.Attachments?.Count ?? 0))</h3>
<div class="attachment-list">
@if (selectedTask.Attachments != null)
{
@foreach (var att in selectedTask.Attachments)
{
<div class="attachment-item">
<span class="attachment-icon">📎</span>
<div class="attachment-info">
<span class="attachment-name">@att.FileName</span>
<span class="attachment-size">@att.FileSizeDisplay</span>
</div>
<button type="button" class="attachment-delete" @onclick="() => DeleteAttachment(att.Id)" title="Löschen">×</button>
</div>
}
}
</div>
<div class="attachment-upload">
<InputFile OnChange="HandleFileUpload" accept="*/*" multiple />
<span class="upload-hint">Max. 10 MB pro Datei</span>
</div>
@if (isUploading)
{
<div class="upload-progress">Wird hochgeladen...</div>
}
</section>
@* Notes - Simple text area *@
<section class="detail-section">
<h3>📝 Notizen</h3>
<textarea class="form-control notes-textarea"
@bind="taskNotesText"
@bind:after="SaveTaskNotes"
rows="6"
placeholder="Notizen zur Aufgabe..."></textarea>
</section>
</div>
@* Chat/Activity Sidebar *@
<aside class="task-detail-sidebar">
<h3>Chat & Aktivität</h3>
<div class="chat-messages" @ref="chatContainer">
@if (selectedTask.Comments != null)
{
@foreach (var comment in selectedTask.Comments.OrderBy(c => c.CreatedAt))
{
<div class="chat-message @(comment.CommentType == KanbanCommentType.Activity ? "activity" : "")">
@if (comment.CommentType == KanbanCommentType.Activity)
{
<div class="activity-content">
<span class="activity-icon">🔄</span>
<span class="activity-text">@comment.Content</span>
<span class="activity-time">@comment.CreatedAt.ToLocalTime().ToString("dd.MM. HH:mm")</span>
</div>
}
else
{
<div class="message-header">
<span class="message-author">@(comment.User?.DisplayName ?? comment.User?.Username ?? "System")</span>
<span class="message-time">@comment.CreatedAt.ToLocalTime().ToString("dd.MM. HH:mm")</span>
</div>
<div class="message-content">@comment.Content</div>
}
</div>
}
}
</div>
<div class="chat-input">
<textarea class="form-control" @bind="newChatMessage" rows="2" placeholder="Nachricht schreiben..."></textarea>
<button type="button" class="btn btn-primary" @onclick="SendChatMessage"
disabled="@string.IsNullOrWhiteSpace(newChatMessage)">Senden</button>
</div>
</aside>
</div>
<div class="modal-footer">
<div class="footer-left">
<span class="task-info">
Erstellt: @selectedTask.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm")
@if (selectedTask.CreatedByUser is { } creator)
{
<span> von @(creator.DisplayName ?? creator.Username)</span>
}
</span>
</div>
<div class="footer-right">
@if (selectedTask.IsArchived)
{
@if (confirmDeleteTask)
{
<button type="button" class="btn btn-outline" @onclick="CancelDeleteTask">Abbrechen</button>
<button type="button" class="btn btn-danger" @onclick="DeleteArchivedTask">🗑 Endgültig löschen</button>
}
else
{
<button type="button" class="btn btn-outline btn-danger" @onclick="() => confirmDeleteTask = true">🗑 Löschen</button>
<button type="button" class="btn btn-primary" @onclick="UnarchiveTask">📦 Wiederherstellen</button>
}
}
else
{
<button type="button" class="btn btn-primary" @onclick="EditSelectedTask">✏️ Bearbeiten</button>
<button type="button" class="btn btn-outline btn-danger" @onclick="ArchiveTask">Archivieren</button>
}
<button type="button" class="btn btn-outline" @onclick="CloseTaskDetailModal">Schließen</button>
</div>
</div>
</div>
}
@* Templates Modal *@
@if (showTemplateModal)
{
<div class="modal-backdrop" @onclick="CloseTemplateModal"></div>
<div class="modal template-modal" role="dialog" aria-labelledby="template-modal-title" aria-modal="true">
<div class="modal-header">
<h2 id="template-modal-title">Vorlagen</h2>
<button type="button" class="modal-close" @onclick="CloseTemplateModal" aria-label="Schließen">×</button>
</div>
<div class="modal-body">
@if (UserSession.IsAdmin)
{
<div class="template-actions">
<button class="btn btn-primary btn-sm" @onclick="ShowCreateTemplateForm">+ Neue Vorlage</button>
</div>
}
@if (showTemplateForm && editingTemplate != null)
{
<div class="template-form">
<h3>@(editingTemplate.Id != Guid.Empty ? "Vorlage bearbeiten" : "Neue Vorlage")</h3>
@* Placeholder Legend *@
<details class="placeholder-legend">
<summary>📋 Verfügbare Platzhalter <span class="legend-hint">– In Titel, Beschreibung &amp; Todos verwendbar</span></summary>
<div class="placeholder-grid">
<div class="placeholder-group">
<h5>👤 Kunde</h5>
<div class="placeholder-item"><code>{Kunde}</code> <span>Vollständiger Name</span></div>
<div class="placeholder-item"><code>{Vorname}</code> <span>Vorname</span></div>
<div class="placeholder-item"><code>{Nachname}</code> <span>Nachname</span></div>
<div class="placeholder-item"><code>{Firma}</code> <span>Firma</span></div>
<div class="placeholder-item"><code>{Kundennummer}</code> <span>Kundennummer</span></div>
<div class="placeholder-item"><code>{Telefon}</code> <span>Telefon</span></div>
<div class="placeholder-item"><code>{Email}</code> <span>E-Mail</span></div>
</div>
<div class="placeholder-group">
<h5>🚗 Fahrzeug</h5>
<div class="placeholder-item"><code>{Fahrzeug}</code> <span>Marke + Modell (Reifengröße)</span></div>
<div class="placeholder-item"><code>{Reifengröße}</code> <span>Reifengröße</span></div>
<div class="placeholder-item"><code>{Marke}</code> <span>Marke</span></div>
<div class="placeholder-item"><code>{Modell}</code> <span>Modell</span></div>
</div>
<div class="placeholder-group">
<h5>📅 Datum</h5>
<div class="placeholder-item"><code>{Datum}</code> <span>Aktuelles Datum</span></div>
<div class="placeholder-item"><code>{Jahr}</code> <span>Aktuelles Jahr</span></div>
</div>
</div>
</details>
<div class="form-group">
<label>Name *</label>
<input type="text" class="form-control" @bind="editingTemplate.Name" placeholder="Vorlagenname..." />
</div>
<div class="form-group">
<label>Beschreibung</label>
<input type="text" class="form-control" @bind="editingTemplate.Description" placeholder="Kurzbeschreibung..." />
</div>
<div class="form-group">
<label>Titel-Muster</label>
<input type="text" class="form-control" @bind="editingTemplate.TitlePattern" placeholder="z.B. Reifenwechsel für {Kunde}" />
</div>
<div class="form-group">
<label>Standard-Beschreibung</label>
<textarea class="form-control" @bind="editingTemplate.DefaultDescription" rows="2" placeholder="z.B. Reifenwechsel {Fahrzeug} – {Reifengröße}"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Standard-Spalte</label>
<select class="form-control" @bind="editingTemplate.DefaultColumnId">
<option value="">-- Keine --</option>
@foreach (var col in columns)
{
<option value="@col.Id">@col.Name</option>
}
</select>
</div>
<div class="form-group">
<label>Standard-Priorität</label>
<select class="form-control" @bind="editingTemplate.DefaultPriority">
<option value="@KanbanTaskPriority.Low">Niedrig</option>
<option value="@KanbanTaskPriority.Medium">Mittel</option>
<option value="@KanbanTaskPriority.High">Hoch</option>
<option value="@KanbanTaskPriority.Urgent">Dringend</option>
</select>
</div>
</div>
<div class="form-group">
<label>Geschätzte Zeit (Min.)</label>
<input type="number" class="form-control" @bind="editingTemplate.DefaultEstimatedMinutes" min="0" />
</div>
@* Template Labels *@
@if (labels.Count > 0)
{
<div class="form-group">
<label>Labels</label>
<div class="label-selector">
@foreach (var label in labels)
{
<label class="label-checkbox" style="--label-color: @label.Color">
<input type="checkbox"
checked="@selectedTemplateLabelIds.Contains(label.Id)"
@onchange="(e) => ToggleTemplateLabel(label.Id, (bool)(e.Value ?? false))" />
<span class="label-tag">@label.Name</span>
</label>
}
</div>
</div>
}
@* Template Todos *@
<div class="form-group">
<label>Todos (@editingTemplateTodos.Count)</label>
<div class="template-todos">
@if (editingTemplateTodos.Count > 0)
{
<div class="template-todo-list">
@for (int i = 0; i < editingTemplateTodos.Count; i++)
{
var todo = editingTemplateTodos[i];
var index = i;
<div class="template-todo-item">
<div class="todo-reorder">
<button class="btn-icon" @onclick="() => MoveTemplateTodoUp(index)" disabled="@(index == 0)" title="Nach oben">▲</button>
<button class="btn-icon" @onclick="() => MoveTemplateTodoDown(index)" disabled="@(index == editingTemplateTodos.Count - 1)" title="Nach unten">▼</button>
</div>
<span class="todo-text">☐ @todo.Text</span>
<button class="btn-icon btn-icon-danger" @onclick="() => RemoveTemplateTodoFromForm(todo)" title="Entfernen">×</button>
</div>
}
</div>
}
<div class="template-todo-add">
<input type="text" class="form-control" @bind="newTemplateTodoText"
@bind:event="oninput"
@onkeydown="HandleTemplateTodoKeyDown"
placeholder="Neues Todo hinzufügen..." />
<button class="btn btn-sm btn-outline" @onclick="AddTemplateTodoToForm"
disabled="@string.IsNullOrWhiteSpace(newTemplateTodoText)">+ Todo</button>
</div>
</div>
</div>
<div class="template-form-actions">
<button class="btn btn-outline" @onclick="CancelTemplateForm">Abbrechen</button>
<button class="btn btn-primary" @onclick="SaveTemplate" disabled="@string.IsNullOrWhiteSpace(editingTemplate.Name)">Speichern</button>
</div>
</div>
}
else
{
<div class="template-list">
@if (templates.Count == 0)
{
<p class="text-muted">Keine Vorlagen vorhanden.</p>
}
else
{
@foreach (var template in templates)
{
<div class="template-item">
<div class="template-info">
<span class="template-name">📋 @template.Name</span>
@if (!string.IsNullOrEmpty(template.Description))
{
<span class="template-description">@template.Description</span>
}
<div class="template-meta">
@if (template.DefaultColumn != null)
{
<span class="meta-item">Spalte: @template.DefaultColumn.Name</span>
}
<span class="meta-item">Priorität: @GetPriorityText(template.DefaultPriority)</span>
@if (template.Todos?.Any() == true)
{
<span class="meta-item">@template.Todos.Count Todos</span>
}
</div>
@if (template.TemplateLabels?.Any() == true)
{
<div class="template-labels">
@foreach (var tl in template.TemplateLabels)
{
if (tl.Label != null)
{
<span class="label-tag" style="--label-color: @tl.Label.Color">@tl.Label.Name</span>
}
}
</div>
}
</div>
<div class="template-item-actions">
<button class="btn btn-sm btn-primary" @onclick="() => OpenCreateFromTemplateModal(template)">Erstellen</button>
@if (UserSession.IsAdmin)
{
<button class="btn btn-sm btn-outline" @onclick="() => EditTemplate(template)">Bearbeiten</button>
<button class="btn btn-sm btn-outline btn-danger" @onclick="() => DeleteTemplate(template.Id)">Löschen</button>
}
</div>
</div>
}
}
</div>
}
</div>
</div>
}
@* Labels Management Modal (Admin only) *@
@if (showLabelsModal && UserSession.IsAdmin)
{
<div class="modal-backdrop" @onclick="CloseLabelsModal"></div>
<div class="modal labels-modal" role="dialog" aria-labelledby="labels-modal-title" aria-modal="true">
<div class="modal-header">
<h2 id="labels-modal-title">Labels verwalten</h2>
<button type="button" class="modal-close" @onclick="CloseLabelsModal" aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div class="label-form">
<input type="text" class="form-control" @bind="newLabelName" placeholder="Label-Name..." />
<input type="color" class="form-control-color" @bind="newLabelColor" />
<button class="btn btn-primary btn-sm" @onclick="AddLabel" disabled="@string.IsNullOrWhiteSpace(newLabelName)">Hinzufügen</button>
</div>
<div class="label-list-manage">
@foreach (var label in labels)
{
<div class="label-item">
<span class="label-tag" style="background-color: @label.Color">@label.Name</span>
<button class="btn btn-sm btn-outline btn-danger" @onclick="() => DeleteLabel(label.Id)">Löschen</button>
</div>
}
</div>
</div>
</div>
}
}
@code {
// === State ===
private bool isLoading = true;
private bool isSaving;
private bool isUploading;
private string? errorMessage;
// === Data ===
private List<KanbanColumn> columns = new();
private List<KanbanTask> tasks = new();
private List<KanbanLabel> labels = new();
private List<KanbanTaskTemplate> templates = new();
private List<Customer> customers = new();
private List<AppUser> availableUsers = new();
private KanbanStatistics statistics = new();
// === Drag & Drop ===
private KanbanTask? draggedTask;
private Guid? dragOverColumnId;
// === Task Modal ===
private bool showTaskModal;
private KanbanTask? editingTask;
private bool isEditingExistingTask;
private List<Guid> selectedLabelIds = new();
private List<Guid> selectedAssigneeIds = new();
private List<Guid> selectedCustomerIds = new();
private Dictionary<Guid, Guid?> selectedVehicleIds = new();
private List<Guid> selectedTireStackIds = new();
private List<TireStack> availableTireStacks = new();
private KanbanTaskTemplate? activeTemplate;
private List<string> pendingTemplateTodos = new();
private string? templateTitlePattern;
private string? templateDescriptionPattern;
// === Task Detail Modal ===
private bool showTaskDetailModal;
private KanbanTask? selectedTask;
private ElementReference chatContainer;
private string newChatMessage = "";
private string newTodoText = "";
private Guid? editingTodoId;
private string editingTodoText = "";
private string taskNotesText = "";
private string taskTireSizeNote = "";
// === Templates Modal ===
private bool showTemplateModal;
private bool showTemplateForm;
private KanbanTaskTemplate? editingTemplate;
private List<KanbanTaskTemplateTodo> editingTemplateTodos = new();
private List<Guid> selectedTemplateLabelIds = new();
private string newTemplateTodoText = "";
// === Labels Modal ===
private bool showLabelsModal;
private string newLabelName = "";
private string newLabelColor = "#3b82f6";
// === Customer Search ===
private string customerSearchText = "";
// === Board Search & Filter ===
private string boardSearchText = "";
private Guid? filterLabelId;
private Guid? filterCustomerId;
private bool showLabelFilter;
private bool showCustomerFilter;
private string filterLabelSearch = "";
private string filterCustomerSearch = "";
private List<KanbanTask> recentlyArchivedTasks = new();
private bool confirmDeleteTask;
private bool HasActiveFilters => !string.IsNullOrWhiteSpace(boardSearchText) || filterLabelId.HasValue || filterCustomerId.HasValue;
protected override async Task OnInitializedAsync()
{
if (UserSession.IsWorkerOrAdmin)
{
await LoadDataSafe();
}
}
private DotNetObjectReference<Dashboard>? _visibilityRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && UserSession.IsWorkerOrAdmin)
{
_visibilityRef = DotNetObjectReference.Create(this);
await VisibilityRefresh.RegisterAsync(JS, _visibilityRef, nameof(OnVisibilityReturn), 120);
}
}
[JSInvokable]
public async Task OnVisibilityReturn(int hiddenSeconds)
{
if (!UserSession.IsWorkerOrAdmin) return;
await LoadDataSafe();
await InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
await VisibilityRefresh.UnregisterAsync(JS);
_visibilityRef?.Dispose();
}
private async Task LoadDataSafe()
{
isLoading = true;
errorMessage = null;
StateHasChanged();
try
{
columns = await KanbanService.GetColumnsAsync();
tasks = await KanbanService.GetTasksAsync();
labels = await KanbanService.GetLabelsAsync();
templates = await KanbanService.GetTemplatesAsync();
customers = await CustomerService.GetAllAsync();
availableUsers = await AuthService.GetAllUsersAsync();
statistics = await KanbanService.GetStatisticsAsync();
recentlyArchivedTasks = await KanbanService.GetRecentlyArchivedTasksAsync(7);
// Auto-archive old completed tasks (run sequentially to avoid DbContext concurrency)
try
{
await KanbanService.ArchiveOldCompletedTasksAsync(30);
}
catch
{
// Logged in service, non-critical
}
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Laden: {ex.Message}";
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task RefreshData()
{
try
{
tasks = await KanbanService.GetTasksAsync();
recentlyArchivedTasks = await KanbanService.GetRecentlyArchivedTasksAsync(7);
statistics = await KanbanService.GetStatisticsAsync();
StateHasChanged();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Aktualisieren: {ex.Message}";
}
}
private List<KanbanTask> GetTasksForColumn(KanbanColumn column)
{
var columnTasks = tasks.Where(t => t.ColumnId == column.Id).AsEnumerable();
// Text search
if (!string.IsNullOrWhiteSpace(boardSearchText))
{
var search = boardSearchText.Trim();
columnTasks = columnTasks.Where(t =>
(t.Title?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) ||
(t.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) == true));
}
// Label filter
if (filterLabelId.HasValue)
{
columnTasks = columnTasks.Where(t =>
t.TaskLabels?.Any(tl => tl.LabelId == filterLabelId.Value) == true);
}
// Customer filter
if (filterCustomerId.HasValue)
{
columnTasks = columnTasks.Where(t =>
t.TaskCustomers?.Any(tc => tc.CustomerId == filterCustomerId.Value) == true);
}
if (column.IsCompletedColumn)
{
// Include recently archived tasks (≤7 days) in the completed column for easy restore
var archivedForColumn = recentlyArchivedTasks.AsEnumerable();
// Apply same filters to archived tasks
if (!string.IsNullOrWhiteSpace(boardSearchText))
{
var search = boardSearchText.Trim();
archivedForColumn = archivedForColumn.Where(t =>
(t.Title?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) ||
(t.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) == true));
}
if (filterLabelId.HasValue)
{
archivedForColumn = archivedForColumn.Where(t =>
t.TaskLabels?.Any(tl => tl.LabelId == filterLabelId.Value) == true);
}
if (filterCustomerId.HasValue)
{
archivedForColumn = archivedForColumn.Where(t =>
t.TaskCustomers?.Any(tc => tc.CustomerId == filterCustomerId.Value) == true);
}
return columnTasks
.Concat(archivedForColumn)
.OrderByDescending(t => t.CompletedAt ?? t.UpdatedAt)
.ToList();
}
return columnTasks.OrderBy(t => t.SortOrder).ToList();
}
private string GetColumnHeaderStyle(KanbanColumn column)
{
if (!string.IsNullOrEmpty(column.Color))
{
return $"border-top-color: {column.Color}";
}
return "";
}
private async Task InitializeDefaultColumns()
{
try
{
// Manually create default columns if they don't exist
var defaultColumns = new[]
{
("📥", "Backlog", "#6b7280", true, false),
("📋", "ToDo", "#3b82f6", false, false),
("🔄", "In Arbeit", "#f59e0b", false, false),
("⏳", "Warte auf Kunden", "#8b5cf6", false, false),
("✅", "Erledigt", "#10b981", false, true)
};
foreach (var (icon, name, color, isDefault, isCompleted) in defaultColumns)
{
await KanbanService.CreateColumnAsync(new KanbanColumn
{
Name = name,
Icon = icon,
Color = color,
IsDefault = isDefault,
IsCompletedColumn = isCompleted
});
}
await LoadDataSafe();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Erstellen der Spalten: {ex.Message}";
}
}
#region Task Card Rendering
private RenderFragment RenderTaskCard(KanbanTask task) => __builder =>
{
<div class="task-card @GetTaskCardClasses(task)"
draggable="true"
@onclick="() => ShowTaskDetail(task)"
@ondragstart="() => HandleDragStart(task)"
@ondragend="HandleDragEnd">
<div class="task-header">
<span class="task-title">
@if (task.IsArchived)
{
<span class="archived-badge" title="Archiviert — klicken zum Wiederherstellen">📦</span>
}
@task.Title
</span>
<div class="task-dropdown" @onclick:stopPropagation>
<select class="column-select" value="@task.ColumnId" @onchange="(e) => OnTaskColumnChange(task.Id, e)">
@foreach (var col in columns)
{
<option value="@col.Id" selected="@(col.Id == task.ColumnId)">@col.Name</option>
}
</select>
</div>
</div>
@if (task.TaskLabels?.Any() == true)
{
<div class="task-labels">
@foreach (var tl in task.TaskLabels.Take(3))
{
@if (tl.Label != null)
{
<span class="label-dot" style="background-color: @tl.Label.Color" title="@tl.Label.Name"></span>
}
}
@if (task.TaskLabels.Count > 3)
{
<span class="label-more">+@(task.TaskLabels.Count - 3)</span>
}
</div>
}
<div class="task-footer">
<div class="task-meta-icons">
@if (task.DueDate.HasValue)
{
<span class="meta-icon due-date @(task.IsOverdue ? "overdue" : task.IsDueToday ? "due-today" : "")"
title="Fällig: @task.DueDate.Value.ToLocalTime().ToString("dd.MM.yyyy")">
📅 @task.DueDate.Value.ToLocalTime().ToString("dd.MM")
</span>
}
@if (task.TodosTotal > 0)
{
<span class="meta-icon todo-progress">✓ @task.TodosCompleted/@task.TodosTotal</span>
}
@if (task.EstimatedMinutes > 0)
{
<span class="meta-icon estimated">⏱ @FormatEstimatedTime(task.EstimatedMinutes)</span>
}
@if (!string.IsNullOrWhiteSpace(task.TireSizeNote))
{
<span class="meta-icon tire-size">🛞 @task.TireSizeNote</span>
}
</div>
@if (task.Assignees?.Any() == true)
{
<div class="task-assignees">
@foreach (var a in task.Assignees.Take(2))
{
<span class="assignee-avatar" title="@(a.User?.DisplayName ?? a.User?.Username ?? "?")">
@GetUserInitial(a.User)
</span>
}
@if (task.Assignees.Count > 2)
{
<span class="assignee-more">+@(task.Assignees.Count - 2)</span>
}
</div>
}
</div>
</div>
};
private string GetTaskCardClasses(KanbanTask task)
{
var classes = new List<string>();
if (task.IsArchived) classes.Add("archived");
else if (task.IsOverdue) classes.Add("overdue");
else if (task.IsDueToday) classes.Add("due-today");
classes.Add($"priority-{task.Priority.ToString().ToLower()}");
if (draggedTask?.Id == task.Id) classes.Add("dragging");
return string.Join(" ", classes);
}
private string FormatEstimatedTime(int? minutes)
{
if (!minutes.HasValue || minutes.Value <= 0) return "";
if (minutes.Value < 60) return $"{minutes.Value}m";
var hours = minutes.Value / 60;
var mins = minutes.Value % 60;
return mins > 0 ? $"{hours}h {mins}m" : $"{hours}h";
}
#endregion
#region Drag & Drop
private void HandleDragStart(KanbanTask task)
{
draggedTask = task;
}
private void HandleDragEnd()
{
draggedTask = null;
dragOverColumnId = null;
StateHasChanged();
}
private void HandleDragOver()
{
// Required for drop to work
}
private void HandleDragEnter(Guid columnId)
{
if (draggedTask != null)
{
dragOverColumnId = columnId;
}
}
private void HandleDragLeave()
{
// Don't clear immediately to avoid flickering
}
private async Task HandleDrop(Guid columnId)
{
if (draggedTask != null && draggedTask.ColumnId != columnId)
{
try
{
await KanbanService.MoveTaskAsync(draggedTask.Id, columnId, 0, UserSession.UserId);
await RefreshData();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Verschieben: {ex.Message}";
}
}
draggedTask = null;
dragOverColumnId = null;
}
private async Task OnTaskColumnChange(Guid taskId, ChangeEventArgs e)
{
if (Guid.TryParse(e.Value?.ToString(), out var newColumnId))
{
try
{
await KanbanService.MoveTaskAsync(taskId, newColumnId, 0, UserSession.UserId);
await RefreshData();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Verschieben: {ex.Message}";
}
}
}
#endregion
#region Task Modal
private void OpenCreateTaskModal(Guid? columnId)
{
var defaultColumnId = columnId ?? columns.FirstOrDefault(c => c.IsDefault)?.Id ?? columns.FirstOrDefault()?.Id ?? Guid.Empty;
editingTask = new KanbanTask
{
ColumnId = defaultColumnId,
Priority = KanbanTaskPriority.Medium
};
isEditingExistingTask = false;
selectedLabelIds.Clear();
selectedAssigneeIds.Clear();
selectedCustomerIds.Clear();
selectedVehicleIds.Clear();
selectedTireStackIds.Clear();
availableTireStacks.Clear();
customerSearchText = "";
showTaskModal = true;
}
private void CloseTaskModal()
{
showTaskModal = false;
editingTask = null;
isEditingExistingTask = false;
activeTemplate = null;
pendingTemplateTodos.Clear();
templateTitlePattern = null;
templateDescriptionPattern = null;
customerSearchText = "";
selectedVehicleIds.Clear();
}
private async Task EditSelectedTask()
{
if (selectedTask == null) return;
// Copy task data for editing
editingTask = new KanbanTask
{
Id = selectedTask.Id,
Title = selectedTask.Title,
Description = selectedTask.Description,
ColumnId = selectedTask.ColumnId,
Priority = selectedTask.Priority,
DueDate = selectedTask.DueDate,
EstimatedMinutes = selectedTask.EstimatedMinutes,
SortOrder = selectedTask.SortOrder,
NotesText = selectedTask.NotesText,
TireSizeNote = selectedTask.TireSizeNote
};
// Populate selected labels
selectedLabelIds = selectedTask.TaskLabels?.Select(tl => tl.LabelId).ToList() ?? new List<Guid>();
// Populate selected assignees
selectedAssigneeIds = selectedTask.Assignees?.Select(a => a.UserId).ToList() ?? new List<Guid>();
// Populate selected customers
selectedCustomerIds = selectedTask.TaskCustomers?.Select(tc => tc.CustomerId).ToList() ?? new List<Guid>();
// Clear vehicle selections (user re-picks vehicles when editing)
selectedVehicleIds.Clear();
// Load tire stacks for selected customers
availableTireStacks.Clear();
await LoadTireStacksForCustomers();
// Populate selected tire stacks
selectedTireStackIds = selectedTask.TaskTireStacks?.Select(ts => ts.TireStackId).ToList() ?? new List<Guid>();
isEditingExistingTask = true;
showTaskDetailModal = false;
showTaskModal = true;
}
private async Task SaveTask()
{
if (editingTask == null || string.IsNullOrWhiteSpace(editingTask.Title))
{
errorMessage = "Bitte geben Sie einen Titel ein.";
return;
}
isSaving = true;
StateHasChanged();
try
{
KanbanTask savedTask;
if (isEditingExistingTask && editingTask.Id != Guid.Empty)
{
savedTask = await KanbanService.UpdateTaskAsync(editingTask, UserSession.UserId);
}
else
{
savedTask = await KanbanService.CreateTaskAsync(editingTask, UserSession.UserId);
}
// Update relations
await KanbanService.SetTaskLabelsAsync(savedTask.Id, selectedLabelIds);
await KanbanService.SetAssigneesAsync(savedTask.Id, selectedAssigneeIds, UserSession.UserId);
var customerVehicleMap = selectedCustomerIds.ToDictionary(
cid => cid,
cid => selectedVehicleIds.TryGetValue(cid, out var vid) ? vid : null);
await KanbanService.SetTaskCustomersAsync(savedTask.Id, customerVehicleMap);
await KanbanService.SetTaskTireStacksAsync(savedTask.Id, selectedTireStackIds);
// Add template todos if creating from template
if (!isEditingExistingTask && pendingTemplateTodos.Count > 0)
{
foreach (var todoText in pendingTemplateTodos)
{
await KanbanService.AddTodoAsync(savedTask.Id, todoText);
}
}
CloseTaskModal();
await RefreshData();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern: {ex.Message}";
}
finally
{
isSaving = false;
StateHasChanged();
}
}
private void ToggleLabel(Guid labelId, bool selected)
{
if (selected && !selectedLabelIds.Contains(labelId))
selectedLabelIds.Add(labelId);
else if (!selected)
selectedLabelIds.Remove(labelId);
}
private void ToggleAssignee(Guid userId, bool selected)
{
if (selected && !selectedAssigneeIds.Contains(userId))
selectedAssigneeIds.Add(userId);
else if (!selected)
selectedAssigneeIds.Remove(userId);
}
private async Task OnCustomerSelected(ChangeEventArgs e)
{
if (Guid.TryParse(e.Value?.ToString(), out var customerId) && !selectedCustomerIds.Contains(customerId))
{
await SelectCustomerById(customerId);
}
}
private List<Customer> GetFilteredCustomers()
{
var available = customers.Where(c => !selectedCustomerIds.Contains(c.Id));
if (!string.IsNullOrWhiteSpace(customerSearchText))
{
var search = customerSearchText.Trim().ToLower();
available = available.Where(c =>
(c.FullName?.ToLower().Contains(search) == true) ||
(c.CustomerNumber?.ToLower().Contains(search) == true) ||
(c.Company?.ToLower().Contains(search) == true) ||
(c.Email?.ToLower().Contains(search) == true) ||
(c.Phone?.ToLower().Contains(search) == true));
}
return available.Take(10).ToList();
}
private async Task SelectCustomerFromSearch(Guid customerId)
{
customerSearchText = "";
await SelectCustomerById(customerId);
}
private async Task SelectCustomerById(Guid customerId)
{
if (!selectedCustomerIds.Contains(customerId))
{
selectedCustomerIds.Add(customerId);
await LoadTireStacksForCustomers();
// Auto-select vehicle if customer has exactly 1 active vehicle
var customer = customers.FirstOrDefault(c => c.Id == customerId);
if (customer != null)
{
var activeVehicles = customer.Vehicles?.Where(v => v.IsActive).ToList() ?? new();
if (activeVehicles.Count == 1)
{
selectedVehicleIds[customerId] = activeVehicles[0].Id;
AutoFillTireSizeFromVehicle(activeVehicles[0]);
}
}
// Resolve template placeholders when first customer is selected
if (activeTemplate != null && editingTask != null && selectedCustomerIds.Count == 1)
{
if (customer != null)
{
var selectedVehicle = selectedVehicleIds.TryGetValue(customerId, out var vid) && vid.HasValue
? customer.Vehicles?.FirstOrDefault(v => v.Id == vid.Value)
: null;
editingTask.Title = ResolveClientPlaceholders(templateTitlePattern, customer, selectedVehicle);
editingTask.Description = ResolveClientPlaceholders(templateDescriptionPattern, customer, selectedVehicle);
pendingTemplateTodos = pendingTemplateTodos
.Select(text => ResolveClientPlaceholders(text, customer, selectedVehicle))
.ToList();
}
}
}
}
private async Task RemoveCustomer(Guid customerId)
{
selectedCustomerIds.Remove(customerId);
selectedVehicleIds.Remove(customerId);
selectedTireStackIds.RemoveAll(id => availableTireStacks.Any(ts => ts.Id == id && ts.CustomerId == customerId));
await LoadTireStacksForCustomers();
}
private void OnVehicleSelected(Guid customerId, Guid? vehicleId)
{
if (vehicleId.HasValue && vehicleId.Value != Guid.Empty)
{
selectedVehicleIds[customerId] = vehicleId.Value;
var customer = customers.FirstOrDefault(c => c.Id == customerId);
var vehicle = customer?.Vehicles?.FirstOrDefault(v => v.Id == vehicleId.Value);
if (vehicle != null)
{
AutoFillTireSizeFromVehicle(vehicle);
}
}
else
{
selectedVehicleIds.Remove(customerId);
}
}
private void AutoFillTireSizeFromVehicle(Vehicle vehicle)
{
if (editingTask != null && string.IsNullOrWhiteSpace(editingTask.TireSizeNote) && !string.IsNullOrWhiteSpace(vehicle.TireSize))
{
editingTask.TireSizeNote = vehicle.TireSize;
}
}
private async Task LoadTireStacksForCustomers()
{
availableTireStacks.Clear();
foreach (var customerId in selectedCustomerIds)
{
try
{
var stacks = await TireStackService.GetByCustomerAsync(customerId);
availableTireStacks.AddRange(stacks);
}
catch { /* ignore errors loading tire stacks */ }
}
StateHasChanged();
}
private void ToggleTireStack(Guid tireStackId, bool selected)
{
if (selected && !selectedTireStackIds.Contains(tireStackId))
selectedTireStackIds.Add(tireStackId);
else if (!selected)
selectedTireStackIds.Remove(tireStackId);
}
#endregion
#region Task Detail Modal
private async Task ShowTaskDetail(KanbanTask task)
{
try
{
selectedTask = await KanbanService.GetTaskByIdAsync(task.Id);
if (selectedTask != null)
{
taskNotesText = selectedTask.NotesText ?? "";
taskTireSizeNote = selectedTask.TireSizeNote ?? "";
showTaskDetailModal = true;
await ScrollChatToBottom();
}
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Laden der Aufgabe: {ex.Message}";
}
}
private void CloseTaskDetailModal()
{
showTaskDetailModal = false;
selectedTask = null;
confirmDeleteTask = false;
ResetDetailInputs();
}
private void ResetDetailInputs()
{
newChatMessage = "";
newTodoText = "";
editingTodoId = null;
editingTodoText = "";
taskNotesText = "";
taskTireSizeNote = "";
}
private async Task ScrollChatToBottom()
{
await Task.Delay(100);
try
{
await JS.InvokeVoidAsync("eval", "document.querySelector('.chat-messages')?.scrollTo(0, document.querySelector('.chat-messages')?.scrollHeight || 0)");
}
catch { /* ignore scroll errors */ }
}
private async Task ReloadSelectedTask()
{
if (selectedTask == null) return;
try
{
selectedTask = await KanbanService.GetTaskByIdAsync(selectedTask.Id);
StateHasChanged();
}
catch { /* ignore reload errors */ }
}
// Todos
private async Task ToggleTodoItem(Guid todoId)
{
try
{
await KanbanService.ToggleTodoAsync(todoId);
await ReloadSelectedTask();
await RefreshData();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
private async Task AddTodo()
{
if (string.IsNullOrWhiteSpace(newTodoText) || selectedTask == null) return;
try
{
await KanbanService.AddTodoAsync(selectedTask.Id, newTodoText.Trim());
newTodoText = "";
await ReloadSelectedTask();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
private void StartEditTodo(KanbanTaskTodo todo)
{
editingTodoId = todo.Id;
editingTodoText = todo.Text;
}
private async Task SaveTodoEdit()
{
if (editingTodoId == null || string.IsNullOrWhiteSpace(editingTodoText))
{
editingTodoId = null;
return;
}
try
{
var todo = selectedTask?.Todos?.FirstOrDefault(t => t.Id == editingTodoId);
if (todo != null)
{
todo.Text = editingTodoText.Trim();
await KanbanService.UpdateTodoAsync(todo);
await ReloadSelectedTask();
}
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
finally
{
editingTodoId = null;
editingTodoText = "";
}
}
private async Task HandleTodoKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter") await SaveTodoEdit();
else if (e.Key == "Escape") { editingTodoId = null; editingTodoText = ""; }
}
private async Task HandleNewTodoKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTodoText))
{
await AddTodo();
}
}
private async Task DeleteTodo(Guid todoId)
{
try
{
await KanbanService.DeleteTodoAsync(todoId);
await ReloadSelectedTask();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
// Chat
private async Task SendChatMessage()
{
if (string.IsNullOrWhiteSpace(newChatMessage) || selectedTask == null) return;
try
{
await KanbanService.AddCommentAsync(selectedTask.Id, newChatMessage.Trim(), UserSession.UserId);
newChatMessage = "";
await ReloadSelectedTask();
await ScrollChatToBottom();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
// Notes - Simple text field
private async Task SaveTaskNotes()
{
if (selectedTask == null) return;
try
{
selectedTask.NotesText = taskNotesText;
await KanbanService.UpdateTaskAsync(selectedTask, UserSession.UserId);
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern der Notizen: {ex.Message}";
}
}
// Tire Size Note - inline editable
private async Task SaveTaskTireSizeNote()
{
if (selectedTask == null) return;
try
{
selectedTask.TireSizeNote = string.IsNullOrWhiteSpace(taskTireSizeNote) ? null : taskTireSizeNote.Trim();
await KanbanService.UpdateTaskAsync(selectedTask, UserSession.UserId);
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern der Reifengröße: {ex.Message}";
}
}
// Attachments
private async Task HandleFileUpload(InputFileChangeEventArgs e)
{
if (selectedTask == null) return;
isUploading = true;
StateHasChanged();
try
{
foreach (var file in e.GetMultipleFiles(10))
{
if (file.Size > 10 * 1024 * 1024)
{
errorMessage = $"Datei '{file.Name}' ist zu groß (max. 10 MB).";
continue;
}
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
await KanbanService.AddAttachmentAsync(selectedTask.Id, file.Name, file.ContentType, ms.ToArray(), UserSession.UserId);
}
await ReloadSelectedTask();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Hochladen: {ex.Message}";
}
finally
{
isUploading = false;
StateHasChanged();
}
}
private async Task DeleteAttachment(Guid attachmentId)
{
try
{
await KanbanService.DeleteAttachmentAsync(attachmentId);
await ReloadSelectedTask();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
// Archive
private async Task ArchiveTask()
{
if (selectedTask == null) return;
try
{
await KanbanService.ArchiveTaskAsync(selectedTask.Id);
CloseTaskDetailModal();
await RefreshData();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
// Unarchive (restore)
private async Task UnarchiveTask()
{
if (selectedTask == null) return;
try
{
await KanbanService.UnarchiveTaskAsync(selectedTask.Id);
CloseTaskDetailModal();
await RefreshData();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
// Delete archived task permanently
private async Task DeleteArchivedTask()
{
if (selectedTask == null || !selectedTask.IsArchived) return;
try
{
await KanbanService.DeleteTaskAsync(selectedTask.Id);
CloseTaskDetailModal();
await RefreshData();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
private void CancelDeleteTask()
{
confirmDeleteTask = false;
}
#endregion
#region Board Search & Filter
private void ToggleLabelFilter()
{
showLabelFilter = !showLabelFilter;
showCustomerFilter = false;
filterLabelSearch = "";
filterCustomerSearch = "";
}
private void ToggleCustomerFilter()
{
showCustomerFilter = !showCustomerFilter;
showLabelFilter = false;
filterCustomerSearch = "";
filterLabelSearch = "";
}
private void SelectLabelFilter(Guid? labelId)
{
filterLabelId = labelId;
showLabelFilter = false;
filterLabelSearch = "";
}
private void SelectCustomerFilter(Guid? customerId)
{
filterCustomerId = customerId;
showCustomerFilter = false;
filterCustomerSearch = "";
}
private void ClearAllFilters()
{
boardSearchText = "";
filterLabelId = null;
filterCustomerId = null;
showLabelFilter = false;
showCustomerFilter = false;
filterLabelSearch = "";
filterCustomerSearch = "";
}
private IEnumerable<KanbanLabel> GetFilteredLabels()
{
if (string.IsNullOrWhiteSpace(filterLabelSearch))
return labels;
var search = filterLabelSearch.Trim();
return labels.Where(l => l.Name.Contains(search, StringComparison.OrdinalIgnoreCase));
}
private IEnumerable<Customer> GetFilteredFilterCustomers()
{
var sorted = customers.OrderBy(c => c.LastName).ThenBy(c => c.FirstName).AsEnumerable();
if (string.IsNullOrWhiteSpace(filterCustomerSearch))
return sorted;
var search = filterCustomerSearch.Trim();
return sorted.Where(c =>
(c.FullName?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) ||
(c.CustomerNumber?.Contains(search, StringComparison.OrdinalIgnoreCase) == true) ||
(c.Company?.Contains(search, StringComparison.OrdinalIgnoreCase) == true));
}
#endregion
#region Templates
private void ShowTemplateModal()
{
showTemplateModal = true;
showTemplateForm = false;
editingTemplate = null;
}
private void CloseTemplateModal()
{
showTemplateModal = false;
showTemplateForm = false;
editingTemplate = null;
editingTemplateTodos.Clear();
selectedTemplateLabelIds.Clear();
newTemplateTodoText = "";
}
private void ShowCreateTemplateForm()
{
editingTemplate = new KanbanTaskTemplate
{
Id = Guid.Empty,
DefaultPriority = KanbanTaskPriority.Medium
};
editingTemplateTodos = new List<KanbanTaskTemplateTodo>();
selectedTemplateLabelIds = new List<Guid>();
newTemplateTodoText = "";
showTemplateForm = true;
}
private void EditTemplate(KanbanTaskTemplate template)
{
editingTemplate = new KanbanTaskTemplate
{
Id = template.Id,
Name = template.Name,
Description = template.Description,
TitlePattern = template.TitlePattern,
DefaultDescription = template.DefaultDescription,
DefaultColumnId = template.DefaultColumnId,
DefaultPriority = template.DefaultPriority,
DefaultEstimatedMinutes = template.DefaultEstimatedMinutes
};
editingTemplateTodos = template.Todos?
.OrderBy(t => t.SortOrder)
.Select(t => new KanbanTaskTemplateTodo { Id = t.Id, TemplateId = t.TemplateId, Text = t.Text, SortOrder = t.SortOrder })
.ToList() ?? new List<KanbanTaskTemplateTodo>();
selectedTemplateLabelIds = template.TemplateLabels?
.Select(tl => tl.LabelId)
.ToList() ?? new List<Guid>();
newTemplateTodoText = "";
showTemplateForm = true;
}
private void CancelTemplateForm()
{
showTemplateForm = false;
editingTemplate = null;
editingTemplateTodos.Clear();
selectedTemplateLabelIds.Clear();
newTemplateTodoText = "";
}
private async Task SaveTemplate()
{
if (editingTemplate == null || string.IsNullOrWhiteSpace(editingTemplate.Name)) return;
try
{
if (editingTemplate.Id == Guid.Empty)
{
var created = await KanbanService.CreateTemplateAsync(editingTemplate);
if (editingTemplateTodos.Count > 0)
{
await KanbanService.SyncTemplateTodosAsync(created.Id, editingTemplateTodos);
}
await KanbanService.SetTemplateLabelsAsync(created.Id, selectedTemplateLabelIds);
}
else
{
await KanbanService.UpdateTemplateAsync(editingTemplate);
await KanbanService.SyncTemplateTodosAsync(editingTemplate.Id, editingTemplateTodos);
await KanbanService.SetTemplateLabelsAsync(editingTemplate.Id, selectedTemplateLabelIds);
}
templates = await KanbanService.GetTemplatesAsync();
CancelTemplateForm();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
private async Task DeleteTemplate(Guid templateId)
{
try
{
await KanbanService.DeleteTemplateAsync(templateId);
templates = await KanbanService.GetTemplatesAsync();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
private void OpenCreateFromTemplateModal(KanbanTaskTemplate template)
{
CloseTemplateModal();
var defaultColumnId = template.DefaultColumnId
?? columns.FirstOrDefault(c => c.IsDefault)?.Id
?? columns.FirstOrDefault()?.Id
?? Guid.Empty;
editingTask = new KanbanTask
{
Title = template.TitlePattern ?? string.Empty,
Description = template.DefaultDescription,
ColumnId = defaultColumnId,
Priority = template.DefaultPriority,
EstimatedMinutes = template.DefaultEstimatedMinutes
};
isEditingExistingTask = false;
selectedLabelIds = template.TemplateLabels?.Select(tl => tl.LabelId).ToList() ?? new();
selectedAssigneeIds.Clear();
selectedCustomerIds.Clear();
selectedVehicleIds.Clear();
selectedTireStackIds.Clear();
availableTireStacks.Clear();
activeTemplate = template;
templateTitlePattern = template.TitlePattern;
templateDescriptionPattern = template.DefaultDescription;
pendingTemplateTodos = template.Todos?
.OrderBy(t => t.SortOrder)
.Select(t => t.Text)
.ToList() ?? new();
showTaskModal = true;
}
private static string ResolveClientPlaceholders(string? text, Customer customer, Vehicle? selectedVehicle = null)
{
if (string.IsNullOrEmpty(text)) return text ?? string.Empty;
var vehicle = selectedVehicle ?? customer.Vehicles?
.Where(v => v.IsActive)
.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;
}
private void AddTemplateTodoToForm()
{
if (string.IsNullOrWhiteSpace(newTemplateTodoText)) return;
editingTemplateTodos.Add(new KanbanTaskTemplateTodo
{
Id = Guid.NewGuid(),
Text = newTemplateTodoText.Trim(),
SortOrder = editingTemplateTodos.Count
});
newTemplateTodoText = "";
}
private void RemoveTemplateTodoFromForm(KanbanTaskTemplateTodo todo)
{
editingTemplateTodos.Remove(todo);
}
private void MoveTemplateTodoUp(int index)
{
if (index <= 0) return;
(editingTemplateTodos[index], editingTemplateTodos[index - 1]) = (editingTemplateTodos[index - 1], editingTemplateTodos[index]);
}
private void MoveTemplateTodoDown(int index)
{
if (index >= editingTemplateTodos.Count - 1) return;
(editingTemplateTodos[index], editingTemplateTodos[index + 1]) = (editingTemplateTodos[index + 1], editingTemplateTodos[index]);
}
private void HandleTemplateTodoKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
AddTemplateTodoToForm();
}
}
private void ToggleTemplateLabel(Guid labelId, bool selected)
{
if (selected && !selectedTemplateLabelIds.Contains(labelId))
selectedTemplateLabelIds.Add(labelId);
else if (!selected)
selectedTemplateLabelIds.Remove(labelId);
}
#endregion
#region Labels
private void ShowLabelsModal()
{
showLabelsModal = true;
newLabelName = "";
newLabelColor = "#3b82f6";
}
private void CloseLabelsModal()
{
showLabelsModal = false;
newLabelName = "";
newLabelColor = "#3b82f6";
}
private async Task AddLabel()
{
if (string.IsNullOrWhiteSpace(newLabelName)) return;
try
{
await KanbanService.CreateLabelAsync(new KanbanLabel
{
Name = newLabelName.Trim(),
Color = newLabelColor
});
newLabelName = "";
newLabelColor = "#3b82f6";
labels = await KanbanService.GetLabelsAsync();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
private async Task DeleteLabel(Guid labelId)
{
try
{
await KanbanService.DeleteLabelAsync(labelId);
labels = await KanbanService.GetLabelsAsync();
}
catch (Exception ex)
{
errorMessage = $"Fehler: {ex.Message}";
}
}
#endregion
#region Helpers
private string GetSeasonIcon(TireSeason season) => season switch
{
TireSeason.Sommer => "☀️",
TireSeason.Winter => "❄️",
TireSeason.Allrad => "🔄",
_ => "🔄"
};
private string GetPriorityIcon(KanbanTaskPriority priority) => priority switch
{
KanbanTaskPriority.Low => "⬇️",
KanbanTaskPriority.Medium => "➡️",
KanbanTaskPriority.High => "⬆️",
KanbanTaskPriority.Urgent => "🔥",
_ => "➡️"
};
private string GetPriorityText(KanbanTaskPriority priority) => priority switch
{
KanbanTaskPriority.Low => "Niedrig",
KanbanTaskPriority.Medium => "Mittel",
KanbanTaskPriority.High => "Hoch",
KanbanTaskPriority.Urgent => "Dringend",
_ => "Mittel"
};
private char GetUserInitial(AppUser? user)
{
if (user == null) return '?';
var name = user.DisplayName ?? user.Username ?? "?";
return name.Length > 0 ? char.ToUpper(name[0]) : '?';
}
#endregion
}