2399 lines
96 KiB
Plaintext
2399 lines
96 KiB
Plaintext
@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 & 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
|
||
}
|