Phase 15B - Feature Request Workflow, Filtering and Admin Productivity

This commit is contained in:
Nick Beckley 2026-06-23 21:38:41 +01:00
parent 955fcb9c0f
commit ac14776e45
12 changed files with 649 additions and 32 deletions

View File

@ -14,9 +14,12 @@ public sealed class AdminController(
{
[HttpGet("")]
[HttpGet("index")]
public IActionResult Index()
public async Task<IActionResult> Index()
{
return View();
return View(new AdminDashboardViewModel
{
FeatureRequestSummary = await featureRequests.GetAdminSummaryAsync()
});
}
[HttpGet("theme-audit")]
@ -26,13 +29,25 @@ public sealed class AdminController(
}
[HttpGet("feature-requests")]
public async Task<IActionResult> FeatureRequests(string? status)
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
{
var selectedStatus = FeatureRequestStatuses.IsValid(status) ? status : null;
var filter = new AdminFeatureRequestFilter
{
Status = FeatureRequestStatuses.IsValid(status) ? status : null,
AppArea = FeatureRequestOptions.AppAreas.Contains(appArea ?? string.Empty, StringComparer.OrdinalIgnoreCase) ? appArea : null,
Importance = FeatureRequestOptions.ImportanceOptions.Contains(importance ?? string.Empty, StringComparer.OrdinalIgnoreCase) ? importance : null,
Search = string.IsNullOrWhiteSpace(search) ? null : search.Trim(),
Sort = FeatureRequestSortOptions.IsValid(sort) ? sort! : FeatureRequestSortOptions.LatestActivity
};
return View(new AdminFeatureRequestIndexViewModel
{
Status = selectedStatus,
Requests = await featureRequests.ListForAdminAsync(selectedStatus)
Status = filter.Status,
AppArea = filter.AppArea,
Importance = filter.Importance,
Search = filter.Search,
Sort = filter.Sort,
Requests = await featureRequests.ListForAdminAsync(filter)
});
}

View File

@ -10,7 +10,7 @@ public interface IFeatureRequestRepository
Task<FeatureRequest?> GetForUserAsync(int featureRequestId, int userId);
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status);
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status, string? appArea, string? importance, string? search, string sort);
Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate);
Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes);
Task<FeatureRequestMessage?> AddMessageAsync(int featureRequestId, int? userId, bool isAdmin, string messageBody);
@ -56,12 +56,12 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
return rows.ToList();
}
public async Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status)
public async Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status, string? appArea, string? importance, string? search, string sort)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<FeatureRequest>(
"dbo.FeatureRequest_ListForAdmin",
new { Status = status },
new { Status = status, AppArea = appArea, Importance = importance, Search = search, Sort = sort },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}

View File

@ -24,6 +24,38 @@ public static class FeatureRequestStatuses
];
public static bool IsValid(string? status) => All.Contains(status ?? string.Empty, StringComparer.OrdinalIgnoreCase);
public static bool IsOpen(string? status) => !string.Equals(status, Shipped, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(status, NotPlanned, StringComparison.OrdinalIgnoreCase);
public static string Explanation(string? status) => status switch
{
New => "Your suggestion has been received and is waiting for review.",
NeedsDetail => "We need a little more information before we can evaluate this suggestion.",
UnderReview => "We are currently evaluating this idea.",
Candidate => "This suggestion is being considered for a future release.",
Planned => "This suggestion has been accepted and is planned for development.",
InProgress => "Work has started on this feature.",
Shipped => "This feature has been completed and released.",
NotPlanned => "This suggestion does not currently fit PlotDirector's roadmap.",
_ => "This suggestion is being reviewed."
};
}
public static class FeatureRequestSortOptions
{
public const string LatestActivity = "LatestActivity";
public const string NewestFirst = "NewestFirst";
public const string OldestFirst = "OldestFirst";
public static IReadOnlyList<(string Value, string Label)> All { get; } =
[
(LatestActivity, "Latest activity"),
(NewestFirst, "Newest first"),
(OldestFirst, "Oldest first")
];
public static bool IsValid(string? sort) => All.Any(option => string.Equals(option.Value, sort, StringComparison.OrdinalIgnoreCase));
}
public static class FeatureRequestOptions
@ -69,6 +101,50 @@ public sealed class FeatureRequest
public DateTime? ClosedUtc { get; set; }
public DateTime? ShippedUtc { get; set; }
public DateTime? LatestActivityUtc { get; set; }
public bool? LatestMessageIsAdmin { get; set; }
public string? LatestMessagePreview { get; set; }
public int MessageCount { get; set; }
public bool IsOpen => FeatureRequestStatuses.IsOpen(Status);
public string StatusExplanation => FeatureRequestStatuses.Explanation(Status);
public string ConversationState
{
get
{
if ((LatestMessageIsAdmin is null || LatestMessageIsAdmin == false) && IsOpen)
{
return FeatureRequestConversationStates.WaitingForAdminReply;
}
if (LatestMessageIsAdmin == true && string.Equals(Status, FeatureRequestStatuses.NeedsDetail, StringComparison.OrdinalIgnoreCase))
{
return FeatureRequestConversationStates.WaitingForUserReply;
}
return FeatureRequestConversationStates.NoActionNeeded;
}
}
}
public static class FeatureRequestConversationStates
{
public const string WaitingForAdminReply = "Waiting for Admin Reply";
public const string WaitingForUserReply = "Waiting for User Reply";
public const string NoActionNeeded = "Up to date";
}
public sealed class FeatureRequestAdminSummary
{
public IReadOnlyList<FeatureRequestStatusSummary> Statuses { get; set; } = [];
public int TotalOpenRequests { get; set; }
public int AwaitingAdminReply { get; set; }
public int AwaitingUserReply { get; set; }
}
public sealed class FeatureRequestStatusSummary
{
public string Status { get; set; } = string.Empty;
public int Count { get; set; }
}
public sealed class FeatureRequestMessage

View File

@ -10,7 +10,8 @@ public interface IFeatureRequestService
Task<FeatureRequest?> GetForUserAsync(int featureRequestId, int userId);
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status);
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(AdminFeatureRequestFilter filter);
Task<FeatureRequestAdminSummary> GetAdminSummaryAsync();
Task<bool> UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model);
Task<bool> AddUserReplyAsync(int featureRequestId, int userId, string messageBody);
Task<bool> AddAdminReplyAsync(int featureRequestId, int adminUserId, string messageBody);
@ -38,8 +39,40 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
public Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId) =>
featureRequests.ListForUserAsync(userId);
public Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status) =>
featureRequests.ListForAdminAsync(FeatureRequestStatuses.IsValid(status) ? status : null);
public Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(AdminFeatureRequestFilter filter)
{
var sanitized = SanitizeFilter(filter);
return featureRequests.ListForAdminAsync(
sanitized.Status,
sanitized.AppArea,
sanitized.Importance,
sanitized.Search,
sanitized.Sort);
}
public async Task<FeatureRequestAdminSummary> GetAdminSummaryAsync()
{
var requests = await featureRequests.ListForAdminAsync(
status: null,
appArea: null,
importance: null,
search: null,
sort: FeatureRequestSortOptions.LatestActivity);
return new FeatureRequestAdminSummary
{
Statuses = FeatureRequestStatuses.All
.Select(status => new FeatureRequestStatusSummary
{
Status = status,
Count = requests.Count(request => string.Equals(request.Status, status, StringComparison.OrdinalIgnoreCase))
})
.ToList(),
TotalOpenRequests = requests.Count(request => request.IsOpen),
AwaitingAdminReply = requests.Count(request => request.ConversationState == FeatureRequestConversationStates.WaitingForAdminReply),
AwaitingUserReply = requests.Count(request => request.ConversationState == FeatureRequestConversationStates.WaitingForUserReply)
};
}
public async Task<bool> UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model)
{
@ -76,4 +109,16 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
private static string Clean(string value) => value.Trim();
private static string? CleanNullable(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static AdminFeatureRequestFilter SanitizeFilter(AdminFeatureRequestFilter filter)
{
return new AdminFeatureRequestFilter
{
Status = FeatureRequestStatuses.IsValid(filter.Status) ? filter.Status : null,
AppArea = FeatureRequestOptions.AppAreas.Contains(filter.AppArea ?? string.Empty, StringComparer.OrdinalIgnoreCase) ? filter.AppArea : null,
Importance = FeatureRequestOptions.ImportanceOptions.Contains(filter.Importance ?? string.Empty, StringComparer.OrdinalIgnoreCase) ? filter.Importance : null,
Search = CleanNullable(filter.Search),
Sort = FeatureRequestSortOptions.IsValid(filter.Sort) ? filter.Sort : FeatureRequestSortOptions.LatestActivity
};
}
}

View File

@ -0,0 +1,156 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_GetForUser
@FeatureRequestID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE fr.FeatureRequestID = @FeatureRequestID
AND fr.UserID = @UserID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_GetForAdmin
@FeatureRequestID int
AS
BEGIN
SET NOCOUNT ON;
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE fr.FeatureRequestID = @FeatureRequestID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListForUser
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
CAST(NULL AS nvarchar(max)) AS AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE fr.UserID = @UserID
ORDER BY COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) DESC, fr.FeatureRequestID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListForAdmin
@Status nvarchar(50) = NULL,
@AppArea nvarchar(100) = NULL,
@Importance nvarchar(50) = NULL,
@Search nvarchar(200) = NULL,
@Sort nvarchar(50) = N'LatestActivity'
AS
BEGIN
SET NOCOUNT ON;
DECLARE @CleanSearch nvarchar(200) = NULLIF(LTRIM(RTRIM(@Search)), N'');
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE (@Status IS NULL OR fr.Status = @Status)
AND (@AppArea IS NULL OR fr.AppArea = @AppArea)
AND (@Importance IS NULL OR fr.Importance = @Importance)
AND
(
@CleanSearch IS NULL
OR fr.Title LIKE N'%' + @CleanSearch + N'%'
OR fr.Description LIKE N'%' + @CleanSearch + N'%'
OR au.DisplayName LIKE N'%' + @CleanSearch + N'%'
OR au.Email LIKE N'%' + @CleanSearch + N'%'
)
ORDER BY
CASE WHEN @Sort = N'OldestFirst' THEN fr.CreatedUtc END ASC,
CASE WHEN @Sort = N'NewestFirst' THEN fr.CreatedUtc END DESC,
CASE WHEN @Sort IS NULL OR @Sort = N'LatestActivity' THEN COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) END DESC,
fr.FeatureRequestID DESC;
END;
GO

View File

@ -50,7 +50,14 @@ public sealed class AdminFeatureRequestIndexViewModel
{
public IReadOnlyList<FeatureRequest> Requests { get; set; } = [];
public string? Status { get; set; }
public string? AppArea { get; set; }
public string? Importance { get; set; }
public string? Search { get; set; }
public string Sort { get; set; } = FeatureRequestSortOptions.LatestActivity;
public IReadOnlyList<string> StatusOptions { get; set; } = FeatureRequestStatuses.All;
public IReadOnlyList<string> AppAreas { get; set; } = FeatureRequestOptions.AppAreas;
public IReadOnlyList<string> ImportanceOptions { get; set; } = FeatureRequestOptions.ImportanceOptions;
public IReadOnlyList<(string Value, string Label)> SortOptions { get; set; } = FeatureRequestSortOptions.All;
}
public sealed class AdminFeatureRequestUpdateViewModel
@ -60,3 +67,17 @@ public sealed class AdminFeatureRequestUpdateViewModel
public bool IsPublicCandidate { get; set; }
public string? AdminNotes { get; set; }
}
public sealed class AdminDashboardViewModel
{
public FeatureRequestAdminSummary FeatureRequestSummary { get; set; } = new();
}
public sealed class AdminFeatureRequestFilter
{
public string? Status { get; set; }
public string? AppArea { get; set; }
public string? Importance { get; set; }
public string? Search { get; set; }
public string Sort { get; set; } = FeatureRequestSortOptions.LatestActivity;
}

View File

@ -1,4 +1,6 @@
@model FeatureRequestDetailsViewModel
@using Microsoft.AspNetCore.Html
@using System.Text.Encodings.Web
@{
ViewData["Title"] = Model.Request.Title;
var request = Model.Request;
@ -15,6 +17,13 @@
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-action="Index">Admin</a>
<span class="muted"> / </span>
<a asp-action="FeatureRequests">Feature Requests</a>
<span class="muted"> / Details</span>
</nav>
@if (TempData["AdminMessage"] is string adminMessage)
{
<div class="alert alert-info">@adminMessage</div>
@ -27,7 +36,11 @@
<div class="row g-3 mb-3">
<div class="col-md-4">
<p class="eyebrow">Status</p>
<p>@request.Status</p>
<p>@StatusBadge(request.Status)</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Conversation</p>
<p>@ConversationBadge(request.ConversationState)</p>
</div>
<div class="col-md-4">
<p class="eyebrow">App area</p>
@ -37,6 +50,14 @@
<p class="eyebrow">Importance</p>
<p>@(request.Importance ?? "Not set")</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Created</p>
<p>@FormatDate(request.CreatedUtc)</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Latest activity</p>
<p>@FormatNullableDate(request.LatestActivityUtc)</p>
</div>
</div>
<p style="white-space: pre-wrap">@request.Description</p>
</section>
@ -88,3 +109,39 @@
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
private static string FormatNullableDate(DateTime? value) => value.HasValue ? FormatDate(value.Value) : "None yet";
private static IHtmlContent StatusBadge(string status)
{
var css = status switch
{
FeatureRequestStatuses.New => "text-bg-primary",
FeatureRequestStatuses.NeedsDetail => "text-bg-warning",
FeatureRequestStatuses.UnderReview => "text-bg-info",
FeatureRequestStatuses.Candidate => "text-bg-secondary",
FeatureRequestStatuses.Planned => "text-bg-success",
FeatureRequestStatuses.InProgress => "text-bg-success",
FeatureRequestStatuses.Shipped => "text-bg-dark",
FeatureRequestStatuses.NotPlanned => "text-bg-light",
_ => "text-bg-secondary"
};
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(status)}</span>");
}
private static IHtmlContent ConversationBadge(string state)
{
var css = state switch
{
FeatureRequestConversationStates.WaitingForAdminReply => "text-bg-danger",
FeatureRequestConversationStates.WaitingForUserReply => "text-bg-warning",
_ => "text-bg-secondary"
};
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(state)}</span>");
}
}

View File

@ -1,4 +1,6 @@
@model AdminFeatureRequestIndexViewModel
@using Microsoft.AspNetCore.Html
@using System.Text.Encodings.Web
@{
ViewData["Title"] = "Feature Requests";
}
@ -8,8 +10,24 @@
<p class="eyebrow">Admin</p>
<h1>Feature Requests</h1>
</div>
<form method="get" class="d-flex gap-2 align-items-end">
<div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Index">Admin dashboard</a>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-action="Index">Admin</a>
<span class="muted"> / Feature Requests</span>
</nav>
@if (TempData["AdminMessage"] is string adminMessage)
{
<div class="alert alert-info">@adminMessage</div>
}
<section class="edit-panel">
<form method="get" class="row g-3 align-items-end">
<div class="col-md-2">
<label class="form-label" for="status">Status</label>
<select class="form-select" id="status" name="status">
<option value="">All statuses</option>
@ -19,14 +37,45 @@
}
</select>
</div>
<button class="btn btn-outline-primary" type="submit">Filter</button>
<div class="col-md-2">
<label class="form-label" for="appArea">App Area</label>
<select class="form-select" id="appArea" name="appArea">
<option value="">All areas</option>
@foreach (var appArea in Model.AppAreas)
{
<option value="@appArea" selected="@(string.Equals(Model.AppArea, appArea, StringComparison.OrdinalIgnoreCase))">@appArea</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label" for="importance">Importance</label>
<select class="form-select" id="importance" name="importance">
<option value="">All importance</option>
@foreach (var importance in Model.ImportanceOptions)
{
<option value="@importance" selected="@(string.Equals(Model.Importance, importance, StringComparison.OrdinalIgnoreCase))">@importance</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label" for="sort">Sort</label>
<select class="form-select" id="sort" name="sort">
@foreach (var sort in Model.SortOptions)
{
<option value="@sort.Value" selected="@(string.Equals(Model.Sort, sort.Value, StringComparison.OrdinalIgnoreCase))">@sort.Label</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label" for="search">Search</label>
<input class="form-control" id="search" name="search" value="@Model.Search" placeholder="Title, description, user or email" />
</div>
<div class="col-md-2 d-flex gap-2">
<button class="btn btn-outline-primary" type="submit">Apply</button>
<a class="btn btn-outline-secondary" asp-action="FeatureRequests">Reset</a>
</div>
</form>
</div>
@if (TempData["AdminMessage"] is string adminMessage)
{
<div class="alert alert-info">@adminMessage</div>
}
</section>
@if (!Model.Requests.Any())
{
@ -46,6 +95,12 @@ else
<th>User</th>
<th>Status</th>
<th>Area</th>
<th>Importance</th>
<th>Created</th>
<th>Latest Activity</th>
<th>Conversation</th>
<th>Messages</th>
<th>Latest message</th>
<th>Updated</th>
<th></th>
</tr>
@ -56,8 +111,14 @@ else
<tr>
<td>@request.Title</td>
<td>@(request.UserDisplayName ?? request.UserEmail)</td>
<td><span class="badge text-bg-secondary">@request.Status</span></td>
<td>@StatusBadge(request.Status)</td>
<td>@(request.AppArea ?? "Not set")</td>
<td>@(request.Importance ?? "Not set")</td>
<td>@FormatDate(request.CreatedUtc)</td>
<td>@FormatNullableDate(request.LatestActivityUtc)</td>
<td>@ConversationBadge(request.ConversationState)</td>
<td>@request.MessageCount.ToString("N0")</td>
<td class="text-truncate" style="max-width: 260px;">@Preview(request.LatestMessagePreview)</td>
<td>@FormatDate(request.UpdatedUtc)</td>
<td class="text-end">
<a class="btn btn-outline-primary btn-sm" asp-action="FeatureRequestDetails" asp-route-id="@request.FeatureRequestID">Open</a>
@ -72,4 +133,47 @@ else
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
private static string FormatNullableDate(DateTime? value) => value.HasValue ? FormatDate(value.Value) : "None yet";
private static string Preview(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "No messages yet";
}
var cleaned = value.ReplaceLineEndings(" ").Trim();
return cleaned.Length <= 120 ? cleaned : $"{cleaned[..120]}...";
}
private static IHtmlContent StatusBadge(string status)
{
var css = status switch
{
FeatureRequestStatuses.New => "text-bg-primary",
FeatureRequestStatuses.NeedsDetail => "text-bg-warning",
FeatureRequestStatuses.UnderReview => "text-bg-info",
FeatureRequestStatuses.Candidate => "text-bg-secondary",
FeatureRequestStatuses.Planned => "text-bg-success",
FeatureRequestStatuses.InProgress => "text-bg-success",
FeatureRequestStatuses.Shipped => "text-bg-dark",
FeatureRequestStatuses.NotPlanned => "text-bg-light",
_ => "text-bg-secondary"
};
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(status)}</span>");
}
private static IHtmlContent ConversationBadge(string state)
{
var css = state switch
{
FeatureRequestConversationStates.WaitingForAdminReply => "text-bg-danger",
FeatureRequestConversationStates.WaitingForUserReply => "text-bg-warning",
_ => "text-bg-secondary"
};
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(state)}</span>");
}
}

View File

@ -1,5 +1,7 @@
@model AdminDashboardViewModel
@{
ViewData["Title"] = "Admin";
var summary = Model.FeatureRequestSummary;
}
<div class="page-heading compact">
@ -10,6 +12,57 @@
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<span class="muted"> / Admin</span>
</nav>
<section class="edit-panel">
<div class="row g-3">
<div class="col-md-4">
<a class="text-decoration-none" asp-action="FeatureRequests">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Open requests</p>
<h2 class="mb-0">@summary.TotalOpenRequests.ToString("N0")</h2>
</div>
</a>
</div>
<div class="col-md-4">
<a class="text-decoration-none" asp-action="FeatureRequests">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Awaiting admin reply</p>
<h2 class="mb-0">@summary.AwaitingAdminReply.ToString("N0")</h2>
</div>
</a>
</div>
<div class="col-md-4">
<a class="text-decoration-none" asp-action="FeatureRequests" asp-route-status="@FeatureRequestStatuses.NeedsDetail">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Awaiting user reply</p>
<h2 class="mb-0">@summary.AwaitingUserReply.ToString("N0")</h2>
</div>
</a>
</div>
</div>
</section>
<section class="edit-panel">
<h2>Feature request status</h2>
<div class="row g-3">
@foreach (var status in summary.Statuses)
{
<div class="col-sm-6 col-lg-3">
<a class="text-decoration-none" asp-action="FeatureRequests" asp-route-status="@status.Status">
<div class="border rounded p-3 h-100">
<p class="eyebrow">@status.Status</p>
<h3 class="mb-0">@status.Count.ToString("N0")</h3>
</div>
</a>
</div>
}
</div>
</section>
<section class="project-card-grid">
<article class="project-card">
<div class="project-card__body">

View File

@ -11,6 +11,11 @@
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-action="Index">Feature Requests</a>
<span class="muted"> / Submit</span>
</nav>
<section class="edit-panel">
<form asp-action="Create" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>

View File

@ -1,4 +1,6 @@
@model FeatureRequestDetailsViewModel
@using Microsoft.AspNetCore.Html
@using System.Text.Encodings.Web
@{
ViewData["Title"] = Model.Request.Title;
var request = Model.Request;
@ -14,12 +16,21 @@
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-action="Index">Feature Requests</a>
<span class="muted"> / Details</span>
</nav>
<section class="edit-panel">
<h2>Request details</h2>
<div class="row g-3 mb-3">
<div class="col-md-3">
<p class="eyebrow">Status</p>
<p>@request.Status</p>
<p>@StatusBadge(request.Status)</p>
</div>
<div class="col-md-3">
<p class="eyebrow">Conversation</p>
<p>@ConversationBadge(request.ConversationState)</p>
</div>
<div class="col-md-3">
<p class="eyebrow">App area</p>
@ -33,7 +44,12 @@
<p class="eyebrow">Updated</p>
<p>@FormatDate(request.UpdatedUtc)</p>
</div>
<div class="col-md-3">
<p class="eyebrow">Latest activity</p>
<p>@FormatNullableDate(request.LatestActivityUtc)</p>
</div>
</div>
<div class="alert alert-info">@request.StatusExplanation</div>
<p style="white-space: pre-wrap">@request.Description</p>
</section>
@ -53,6 +69,38 @@
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
private static string FormatNullableDate(DateTime? value) => value.HasValue ? FormatDate(value.Value) : "None yet";
private static IHtmlContent StatusBadge(string status)
{
var css = status switch
{
FeatureRequestStatuses.New => "text-bg-primary",
FeatureRequestStatuses.NeedsDetail => "text-bg-warning",
FeatureRequestStatuses.UnderReview => "text-bg-info",
FeatureRequestStatuses.Candidate => "text-bg-secondary",
FeatureRequestStatuses.Planned => "text-bg-success",
FeatureRequestStatuses.InProgress => "text-bg-success",
FeatureRequestStatuses.Shipped => "text-bg-dark",
FeatureRequestStatuses.NotPlanned => "text-bg-light",
_ => "text-bg-secondary"
};
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(status)}</span>");
}
private static IHtmlContent ConversationBadge(string state)
{
var css = state switch
{
FeatureRequestConversationStates.WaitingForAdminReply => "text-bg-info",
FeatureRequestConversationStates.WaitingForUserReply => "text-bg-warning",
_ => "text-bg-secondary"
};
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(state)}</span>");
}
}
@section Scripts {

View File

@ -1,4 +1,6 @@
@model FeatureRequestIndexViewModel
@using Microsoft.AspNetCore.Html
@using System.Text.Encodings.Web
@{
ViewData["Title"] = "Feature Requests";
}
@ -14,6 +16,11 @@
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<span class="muted"> / Feature Requests</span>
</nav>
@if (TempData["FeatureRequestMessage"] is string featureRequestMessage)
{
<div class="alert alert-info">@featureRequestMessage</div>
@ -35,12 +42,11 @@ else
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Latest Activity</th>
<th>Conversation</th>
<th>App Area</th>
<th>Importance</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<th>Latest Activity</th>
<th></th>
</tr>
</thead>
@ -49,12 +55,11 @@ else
{
<tr>
<td>@request.Title</td>
<td>@StatusBadge(request.Status)</td>
<td>@FormatNullableDate(request.LatestActivityUtc)</td>
<td>@ConversationBadge(request.ConversationState)</td>
<td>@(request.AppArea ?? "Not set")</td>
<td>@(request.Importance ?? "Not set")</td>
<td><span class="badge text-bg-secondary">@request.Status</span></td>
<td>@FormatDate(request.CreatedUtc)</td>
<td>@FormatDate(request.UpdatedUtc)</td>
<td>@(request.LatestActivityUtc.HasValue ? FormatDate(request.LatestActivityUtc.Value) : "None yet")</td>
<td class="text-end">
<a class="btn btn-outline-primary btn-sm" asp-action="Details" asp-route-id="@request.FeatureRequestID">Open</a>
</td>
@ -68,4 +73,36 @@ else
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
private static string FormatNullableDate(DateTime? value) => value.HasValue ? FormatDate(value.Value) : "None yet";
private static IHtmlContent StatusBadge(string status)
{
var css = status switch
{
FeatureRequestStatuses.New => "text-bg-primary",
FeatureRequestStatuses.NeedsDetail => "text-bg-warning",
FeatureRequestStatuses.UnderReview => "text-bg-info",
FeatureRequestStatuses.Candidate => "text-bg-secondary",
FeatureRequestStatuses.Planned => "text-bg-success",
FeatureRequestStatuses.InProgress => "text-bg-success",
FeatureRequestStatuses.Shipped => "text-bg-dark",
FeatureRequestStatuses.NotPlanned => "text-bg-light",
_ => "text-bg-secondary"
};
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(status)}</span>");
}
private static IHtmlContent ConversationBadge(string state)
{
var css = state switch
{
FeatureRequestConversationStates.WaitingForAdminReply => "text-bg-info",
FeatureRequestConversationStates.WaitingForUserReply => "text-bg-warning",
_ => "text-bg-secondary"
};
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(state)}</span>");
}
}