feat(web): add task detail panel with inline editing, subtasks, and notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,113 @@ public class BoardModel : PageModel
|
|||||||
return await ReturnBoardContentAsync();
|
return await ReturnBoardContentAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load task detail panel
|
||||||
|
public async Task<IActionResult> OnGetTaskDetailAsync(int id)
|
||||||
|
{
|
||||||
|
var task = await _taskRepo.GetByIdAsync(id);
|
||||||
|
if (task is null) return NotFound();
|
||||||
|
return Partial("Partials/_TaskDetail", task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update task fields (inline edit)
|
||||||
|
public async Task<IActionResult> OnPutUpdateTaskAsync(int id, string? title, string? description, string? category, int? estimatedMinutes)
|
||||||
|
{
|
||||||
|
var task = await _taskRepo.GetByIdAsync(id);
|
||||||
|
if (task is null) return NotFound();
|
||||||
|
|
||||||
|
if (title is not null) task.Title = title;
|
||||||
|
if (description is not null) task.Description = description;
|
||||||
|
if (category is not null) task.Category = category;
|
||||||
|
if (estimatedMinutes.HasValue) task.EstimatedMinutes = estimatedMinutes;
|
||||||
|
|
||||||
|
await _taskRepo.UpdateAsync(task);
|
||||||
|
return Partial("Partials/_TaskDetail", task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subtask
|
||||||
|
public async Task<IActionResult> OnPostAddSubtaskAsync(int id, string title)
|
||||||
|
{
|
||||||
|
var parent = await _taskRepo.GetByIdAsync(id);
|
||||||
|
if (parent is null) return NotFound();
|
||||||
|
|
||||||
|
var subtask = new WorkTask
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
ParentTaskId = id,
|
||||||
|
Status = WorkTaskStatus.Pending
|
||||||
|
};
|
||||||
|
await _taskRepo.CreateAsync(subtask);
|
||||||
|
|
||||||
|
// Reload parent to get updated subtask list
|
||||||
|
parent = await _taskRepo.GetByIdAsync(id);
|
||||||
|
return Partial("Partials/_SubtaskList", parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete subtask
|
||||||
|
public async Task<IActionResult> OnPutCompleteSubtaskAsync(int id)
|
||||||
|
{
|
||||||
|
var task = await _taskRepo.GetByIdAsync(id);
|
||||||
|
if (task is null) return NotFound();
|
||||||
|
|
||||||
|
task.Status = WorkTaskStatus.Completed;
|
||||||
|
task.CompletedAt = DateTime.UtcNow;
|
||||||
|
await _taskRepo.UpdateAsync(task);
|
||||||
|
|
||||||
|
// Return parent's subtask list
|
||||||
|
if (task.ParentTaskId.HasValue)
|
||||||
|
{
|
||||||
|
var parent = await _taskRepo.GetByIdAsync(task.ParentTaskId.Value);
|
||||||
|
if (parent is not null)
|
||||||
|
return Partial("Partials/_SubtaskList", parent);
|
||||||
|
}
|
||||||
|
return Content("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add note
|
||||||
|
public async Task<IActionResult> OnPostAddNoteAsync(int id, string content)
|
||||||
|
{
|
||||||
|
var task = await _taskRepo.GetByIdAsync(id);
|
||||||
|
if (task is null) return NotFound();
|
||||||
|
|
||||||
|
task.Notes.Add(new TaskNote
|
||||||
|
{
|
||||||
|
Content = content,
|
||||||
|
Type = NoteType.General,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _taskRepo.UpdateAsync(task);
|
||||||
|
|
||||||
|
return Partial("Partials/_NotesList", task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search tasks (for Ctrl+K modal)
|
||||||
|
public async Task<IActionResult> OnGetSearchAsync(string? q)
|
||||||
|
{
|
||||||
|
var allTasks = await _taskRepo.GetAllAsync();
|
||||||
|
List<WorkTask> results;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
results = allTasks
|
||||||
|
.Where(t => t.Status is WorkTaskStatus.Active or WorkTaskStatus.Paused or WorkTaskStatus.Pending)
|
||||||
|
.OrderByDescending(t => t.Status == WorkTaskStatus.Active)
|
||||||
|
.Take(8)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
results = allTasks
|
||||||
|
.Where(t =>
|
||||||
|
t.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(t.Description?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||||
|
(t.Category?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||||
|
.Take(10)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Partial("Partials/_SearchResults", results);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> ReturnBoardContentAsync()
|
private async Task<IActionResult> ReturnBoardContentAsync()
|
||||||
{
|
{
|
||||||
await LoadBoardDataAsync();
|
await LoadBoardDataAsync();
|
||||||
|
|||||||
64
TaskTracker.Api/Pages/Partials/_NotesList.cshtml
Normal file
64
TaskTracker.Api/Pages/Partials/_NotesList.cshtml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
@using TaskTracker.Core.Enums
|
||||||
|
@model TaskTracker.Core.Entities.WorkTask
|
||||||
|
|
||||||
|
@functions {
|
||||||
|
static string FormatRelativeTime(DateTime dt)
|
||||||
|
{
|
||||||
|
var diff = DateTime.UtcNow - dt;
|
||||||
|
if (diff.TotalMinutes < 1) return "just now";
|
||||||
|
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
|
||||||
|
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
|
||||||
|
if (diff.TotalDays < 2) return "yesterday";
|
||||||
|
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
|
||||||
|
if (diff.TotalDays < 30) return $"{(int)(diff.TotalDays / 7)}w ago";
|
||||||
|
return dt.ToLocalTime().ToString("MMM d, yyyy");
|
||||||
|
}
|
||||||
|
|
||||||
|
static string GetNoteTypeCssClass(NoteType type)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
NoteType.PauseNote => "note-type-badge--pause",
|
||||||
|
NoteType.ResumeNote => "note-type-badge--resume",
|
||||||
|
_ => "note-type-badge--general"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static string GetNoteTypeLabel(NoteType type)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
NoteType.PauseNote => "Pause",
|
||||||
|
NoteType.ResumeNote => "Resume",
|
||||||
|
_ => "General"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div id="notes-list-@Model.Id">
|
||||||
|
<h3 class="detail-section-label">Notes</h3>
|
||||||
|
|
||||||
|
@foreach (var note in Model.Notes.OrderBy(n => n.CreatedAt))
|
||||||
|
{
|
||||||
|
<div class="note">
|
||||||
|
<div class="note-header">
|
||||||
|
<span class="note-type-badge @GetNoteTypeCssClass(note.Type)">
|
||||||
|
@GetNoteTypeLabel(note.Type)
|
||||||
|
</span>
|
||||||
|
<span class="note-time">@FormatRelativeTime(note.CreatedAt)</span>
|
||||||
|
</div>
|
||||||
|
<div class="note-content">@note.Content</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Add note form -->
|
||||||
|
<form hx-post="/board?handler=AddNote&id=@Model.Id"
|
||||||
|
hx-target="#notes-list-@Model.Id"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="note-add-form"
|
||||||
|
style="margin-top: 8px;">
|
||||||
|
<input type="text" name="content" placeholder="Add a note..." class="input"
|
||||||
|
style="font-size: 13px; padding: 6px 10px;"
|
||||||
|
autocomplete="off" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
33
TaskTracker.Api/Pages/Partials/_SearchResults.cshtml
Normal file
33
TaskTracker.Api/Pages/Partials/_SearchResults.cshtml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@using TaskTracker.Api.Pages
|
||||||
|
@using TaskTracker.Core.Enums
|
||||||
|
@model List<TaskTracker.Core.Entities.WorkTask>
|
||||||
|
|
||||||
|
@* Placeholder — full implementation in Task 6 (Search Modal) *@
|
||||||
|
<div class="search-results">
|
||||||
|
@if (Model.Count == 0)
|
||||||
|
{
|
||||||
|
<div style="padding: 16px; text-align: center; color: var(--color-text-secondary); font-size: 13px;">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var task in Model)
|
||||||
|
{
|
||||||
|
<div class="search-result-item"
|
||||||
|
hx-get="/board?handler=TaskDetail&id=@task.Id"
|
||||||
|
hx-target="#detail-panel"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
style="padding: 10px 16px; cursor: pointer; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--color-border);">
|
||||||
|
<span class="badge badge--@task.Status.ToString().ToLower()" style="flex-shrink: 0;">
|
||||||
|
@task.Status
|
||||||
|
</span>
|
||||||
|
<span style="font-size: 14px; color: var(--color-text-primary);">@task.Title</span>
|
||||||
|
@if (!string.IsNullOrEmpty(task.Category))
|
||||||
|
{
|
||||||
|
<span style="margin-left: auto; font-size: 11px; color: var(--color-text-tertiary);">@task.Category</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
46
TaskTracker.Api/Pages/Partials/_SubtaskList.cshtml
Normal file
46
TaskTracker.Api/Pages/Partials/_SubtaskList.cshtml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
@using TaskTracker.Core.Enums
|
||||||
|
@model TaskTracker.Core.Entities.WorkTask
|
||||||
|
|
||||||
|
<div id="subtask-list-@Model.Id">
|
||||||
|
<h3 class="detail-section-label">Subtasks</h3>
|
||||||
|
|
||||||
|
@foreach (var sub in Model.SubTasks)
|
||||||
|
{
|
||||||
|
<div class="subtask-row">
|
||||||
|
@if (sub.Status == WorkTaskStatus.Completed)
|
||||||
|
{
|
||||||
|
<span class="subtask-check subtask-check--done">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="subtask-title subtask-title--done">@sub.Title</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="subtask-check"
|
||||||
|
hx-put="/board?handler=CompleteSubtask&id=@sub.Id"
|
||||||
|
hx-target="#subtask-list-@Model.Id"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="3" ry="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="subtask-title">@sub.Title</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Add subtask form -->
|
||||||
|
<form hx-post="/board?handler=AddSubtask&id=@Model.Id"
|
||||||
|
hx-target="#subtask-list-@Model.Id"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="subtask-add-form"
|
||||||
|
style="margin-top: 8px;">
|
||||||
|
<input type="text" name="title" placeholder="Add subtask..." class="input"
|
||||||
|
style="font-size: 13px; padding: 6px 10px;"
|
||||||
|
autocomplete="off" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
323
TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml
Normal file
323
TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
@using TaskTracker.Api.Pages
|
||||||
|
@using TaskTracker.Core.Enums
|
||||||
|
@model TaskTracker.Core.Entities.WorkTask
|
||||||
|
|
||||||
|
@{
|
||||||
|
var statusColors = new Dictionary<WorkTaskStatus, (string Color, string Label)>
|
||||||
|
{
|
||||||
|
[WorkTaskStatus.Pending] = ("#64748b", "Pending"),
|
||||||
|
[WorkTaskStatus.Active] = ("#3b82f6", "Active"),
|
||||||
|
[WorkTaskStatus.Paused] = ("#eab308", "Paused"),
|
||||||
|
[WorkTaskStatus.Completed] = ("#22c55e", "Completed"),
|
||||||
|
[WorkTaskStatus.Abandoned] = ("#ef4444", "Abandoned"),
|
||||||
|
};
|
||||||
|
var (statusColor, statusLabel) = statusColors[Model.Status];
|
||||||
|
var catColor = BoardModel.GetCategoryColor(Model.Category);
|
||||||
|
var elapsed = BoardModel.FormatElapsed(Model.StartedAt, Model.CompletedAt);
|
||||||
|
|
||||||
|
double? progressPercent = null;
|
||||||
|
if (Model.EstimatedMinutes.HasValue && Model.StartedAt.HasValue)
|
||||||
|
{
|
||||||
|
var start = Model.StartedAt.Value;
|
||||||
|
var end = Model.CompletedAt ?? DateTime.UtcNow;
|
||||||
|
var elapsedMins = (end - start).TotalMinutes;
|
||||||
|
progressPercent = Math.Min(100, (elapsedMins / Model.EstimatedMinutes.Value) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
var isTerminal = Model.Status is WorkTaskStatus.Completed or WorkTaskStatus.Abandoned;
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div class="detail-overlay" onclick="closeDetailPanel()"></div>
|
||||||
|
|
||||||
|
<!-- Panel -->
|
||||||
|
<div class="detail-panel">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="detail-header">
|
||||||
|
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;">
|
||||||
|
<div class="inline-edit" id="edit-title" style="flex: 1; min-width: 0;">
|
||||||
|
<h2 class="detail-title inline-edit-display inline-edit-display--title"
|
||||||
|
onclick="startEdit('title')">@Model.Title</h2>
|
||||||
|
<form class="inline-edit-form" style="display:none"
|
||||||
|
hx-put="/board?handler=UpdateTask&id=@Model.Id"
|
||||||
|
hx-target="#detail-panel"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<input type="text" name="title" value="@Model.Title"
|
||||||
|
class="inline-edit-input"
|
||||||
|
onblur="this.form.requestSubmit()"
|
||||||
|
onkeydown="if(event.key==='Escape'){cancelEdit('title');event.stopPropagation()}"
|
||||||
|
onkeypress="if(event.key==='Enter'){this.form.requestSubmit();event.preventDefault()}" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<button class="detail-close" onclick="closeDetailPanel()">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status badge + Category -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; margin-top: 12px;">
|
||||||
|
<span class="badge badge--@statusLabel.ToLower()">@statusLabel</span>
|
||||||
|
|
||||||
|
<div class="inline-edit" id="edit-category">
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Category))
|
||||||
|
{
|
||||||
|
<span class="inline-edit-display inline-edit-display--field"
|
||||||
|
onclick="startEdit('category')"
|
||||||
|
style="font-size: 12px; display: inline-flex; align-items: center; gap: 4px;">
|
||||||
|
<span style="width: 8px; height: 8px; border-radius: 50%; background: @catColor; flex-shrink: 0;"></span>
|
||||||
|
@Model.Category
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="inline-edit-display inline-edit-display--field"
|
||||||
|
onclick="startEdit('category')"
|
||||||
|
style="font-size: 12px; color: var(--color-text-tertiary);">
|
||||||
|
+ category
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<form class="inline-edit-form" style="display:none"
|
||||||
|
hx-put="/board?handler=UpdateTask&id=@Model.Id"
|
||||||
|
hx-target="#detail-panel"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<input type="text" name="category" value="@(Model.Category ?? "")"
|
||||||
|
class="inline-edit-input" placeholder="Category"
|
||||||
|
onblur="this.form.requestSubmit()"
|
||||||
|
onkeydown="if(event.key==='Escape'){cancelEdit('category');event.stopPropagation()}"
|
||||||
|
onkeypress="if(event.key==='Enter'){this.form.requestSubmit();event.preventDefault()}" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable body -->
|
||||||
|
<div class="detail-body">
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3 class="detail-section-label">Description</h3>
|
||||||
|
<div class="inline-edit" id="edit-description">
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Description))
|
||||||
|
{
|
||||||
|
<div class="inline-edit-display inline-edit-display--field"
|
||||||
|
onclick="startEdit('description')"
|
||||||
|
style="font-size: 13px; line-height: 1.6; white-space: pre-wrap;">@Model.Description</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="inline-edit-display inline-edit-display--field"
|
||||||
|
onclick="startEdit('description')"
|
||||||
|
style="font-size: 13px; color: var(--color-text-tertiary);">
|
||||||
|
Click to add description...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<form class="inline-edit-form" style="display:none"
|
||||||
|
hx-put="/board?handler=UpdateTask&id=@Model.Id"
|
||||||
|
hx-target="#detail-panel"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<textarea name="description" rows="4"
|
||||||
|
class="inline-edit-input"
|
||||||
|
onkeydown="if(event.key==='Escape'){cancelEdit('description');event.stopPropagation()}"
|
||||||
|
style="resize: vertical;">@(Model.Description ?? "")</textarea>
|
||||||
|
<div style="display: flex; gap: 6px; margin-top: 6px;">
|
||||||
|
<button type="submit" class="btn btn--sm btn--primary">Save</button>
|
||||||
|
<button type="button" class="btn btn--sm btn--ghost" onclick="cancelEdit('description')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time section -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3 class="detail-section-label">Time</h3>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 13px;">
|
||||||
|
<span style="color: var(--color-text-secondary);">Elapsed</span>
|
||||||
|
<span style="color: var(--color-text-primary); font-variant-numeric: tabular-nums;">@elapsed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 13px;">
|
||||||
|
<span style="color: var(--color-text-secondary);">Estimate</span>
|
||||||
|
<div class="inline-edit" id="edit-estimate" style="text-align: right;">
|
||||||
|
<span class="inline-edit-display inline-edit-display--field"
|
||||||
|
onclick="startEdit('estimate')"
|
||||||
|
style="color: var(--color-text-primary); font-variant-numeric: tabular-nums; padding: 2px 8px;">
|
||||||
|
@(Model.EstimatedMinutes.HasValue ? $"{Model.EstimatedMinutes}m" : "--")
|
||||||
|
</span>
|
||||||
|
<form class="inline-edit-form" style="display:none"
|
||||||
|
hx-put="/board?handler=UpdateTask&id=@Model.Id"
|
||||||
|
hx-target="#detail-panel"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<input type="number" name="estimatedMinutes"
|
||||||
|
value="@(Model.EstimatedMinutes?.ToString() ?? "")"
|
||||||
|
class="inline-edit-input" placeholder="minutes"
|
||||||
|
style="width: 100px; text-align: right;"
|
||||||
|
onblur="this.form.requestSubmit()"
|
||||||
|
onkeydown="if(event.key==='Escape'){cancelEdit('estimate');event.stopPropagation()}"
|
||||||
|
onkeypress="if(event.key==='Enter'){this.form.requestSubmit();event.preventDefault()}" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (progressPercent.HasValue)
|
||||||
|
{
|
||||||
|
var isOver = progressPercent.Value >= 100;
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-bar-fill @(isOver ? "progress-bar-fill--over" : "")"
|
||||||
|
style="width: @progressPercent.Value.ToString("F0")%"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subtasks -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<partial name="Partials/_SubtaskList" model="Model" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<partial name="Partials/_NotesList" model="Model" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons (sticky bottom) -->
|
||||||
|
@if (!isTerminal)
|
||||||
|
{
|
||||||
|
<div class="detail-actions">
|
||||||
|
@switch (Model.Status)
|
||||||
|
{
|
||||||
|
case WorkTaskStatus.Pending:
|
||||||
|
<button hx-put="/board?handler=Start&id=@Model.Id"
|
||||||
|
hx-target="#kanban-board"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn--primary btn--full"
|
||||||
|
onclick="closeDetailPanel()">
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button hx-delete="/board?handler=Abandon&id=@Model.Id"
|
||||||
|
hx-target="#kanban-board"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn--danger btn--full"
|
||||||
|
onclick="closeDetailPanel()">
|
||||||
|
Abandon
|
||||||
|
</button>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WorkTaskStatus.Active:
|
||||||
|
<button hx-put="/board?handler=Pause&id=@Model.Id"
|
||||||
|
hx-target="#kanban-board"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn--amber btn--full"
|
||||||
|
onclick="closeDetailPanel()">
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
<button hx-put="/board?handler=Complete&id=@Model.Id"
|
||||||
|
hx-target="#kanban-board"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn--emerald btn--full"
|
||||||
|
onclick="closeDetailPanel()">
|
||||||
|
Complete
|
||||||
|
</button>
|
||||||
|
<button hx-delete="/board?handler=Abandon&id=@Model.Id"
|
||||||
|
hx-target="#kanban-board"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn--danger btn--full"
|
||||||
|
onclick="closeDetailPanel()">
|
||||||
|
Abandon
|
||||||
|
</button>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WorkTaskStatus.Paused:
|
||||||
|
<button hx-put="/board?handler=Resume&id=@Model.Id"
|
||||||
|
hx-target="#kanban-board"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn--primary btn--full"
|
||||||
|
onclick="closeDetailPanel()">
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
<button hx-put="/board?handler=Complete&id=@Model.Id"
|
||||||
|
hx-target="#kanban-board"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn--emerald btn--full"
|
||||||
|
onclick="closeDetailPanel()">
|
||||||
|
Complete
|
||||||
|
</button>
|
||||||
|
<button hx-delete="/board?handler=Abandon&id=@Model.Id"
|
||||||
|
hx-target="#kanban-board"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn--danger btn--full"
|
||||||
|
onclick="closeDetailPanel()">
|
||||||
|
Abandon
|
||||||
|
</button>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Open the detail panel when this partial is loaded
|
||||||
|
(function() {
|
||||||
|
var overlay = document.querySelector('.detail-overlay');
|
||||||
|
var panel = document.querySelector('.detail-panel');
|
||||||
|
// Use requestAnimationFrame to ensure the DOM is painted before adding classes
|
||||||
|
requestAnimationFrame(function() {
|
||||||
|
if (overlay) overlay.classList.add('detail-overlay--open');
|
||||||
|
if (panel) panel.classList.add('detail-panel--open');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function closeDetailPanel() {
|
||||||
|
var overlay = document.querySelector('.detail-overlay');
|
||||||
|
var panel = document.querySelector('.detail-panel');
|
||||||
|
if (overlay) overlay.classList.remove('detail-overlay--open');
|
||||||
|
if (panel) panel.classList.remove('detail-panel--open');
|
||||||
|
// Clear the panel content after the transition
|
||||||
|
setTimeout(function() {
|
||||||
|
var container = document.getElementById('detail-panel');
|
||||||
|
if (container) container.innerHTML = '';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(field) {
|
||||||
|
var wrapper = document.getElementById('edit-' + field);
|
||||||
|
if (!wrapper) return;
|
||||||
|
var display = wrapper.querySelector('.inline-edit-display');
|
||||||
|
var form = wrapper.querySelector('.inline-edit-form');
|
||||||
|
if (display) display.style.display = 'none';
|
||||||
|
if (form) {
|
||||||
|
form.style.display = '';
|
||||||
|
var input = form.querySelector('input, textarea');
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
if (input.type === 'text' || input.tagName === 'TEXTAREA') {
|
||||||
|
input.setSelectionRange(input.value.length, input.value.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit(field) {
|
||||||
|
var wrapper = document.getElementById('edit-' + field);
|
||||||
|
if (!wrapper) return;
|
||||||
|
var display = wrapper.querySelector('.inline-edit-display');
|
||||||
|
var form = wrapper.querySelector('.inline-edit-form');
|
||||||
|
if (display) display.style.display = '';
|
||||||
|
if (form) form.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close panel on Escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
var panel = document.querySelector('.detail-panel--open');
|
||||||
|
if (panel) {
|
||||||
|
closeDetailPanel();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user