diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index 3a48c92..93b43a5 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -14,9 +14,12 @@ public sealed class AdminController( { [HttpGet("")] [HttpGet("index")] - public IActionResult Index() + public async Task 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 FeatureRequests(string? status) + public async Task 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) }); } diff --git a/PlotLine/Data/FeatureRequestRepository.cs b/PlotLine/Data/FeatureRequestRepository.cs index 5d8f99b..b8b9113 100644 --- a/PlotLine/Data/FeatureRequestRepository.cs +++ b/PlotLine/Data/FeatureRequestRepository.cs @@ -10,7 +10,7 @@ public interface IFeatureRequestRepository Task GetForUserAsync(int featureRequestId, int userId); Task GetForAdminAsync(int featureRequestId); Task> ListForUserAsync(int userId); - Task> ListForAdminAsync(string? status); + Task> ListForAdminAsync(string? status, string? appArea, string? importance, string? search, string sort); Task UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate); Task UpdateAdminNotesAsync(int featureRequestId, string? adminNotes); Task 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> ListForAdminAsync(string? status) + public async Task> ListForAdminAsync(string? status, string? appArea, string? importance, string? search, string sort) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.FeatureRequest_ListForAdmin", - new { Status = status }, + new { Status = status, AppArea = appArea, Importance = importance, Search = search, Sort = sort }, commandType: CommandType.StoredProcedure); return rows.ToList(); } diff --git a/PlotLine/Models/FeatureRequestModels.cs b/PlotLine/Models/FeatureRequestModels.cs index 51a28a5..9f61777 100644 --- a/PlotLine/Models/FeatureRequestModels.cs +++ b/PlotLine/Models/FeatureRequestModels.cs @@ -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 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 diff --git a/PlotLine/Services/FeatureRequestService.cs b/PlotLine/Services/FeatureRequestService.cs index 31d9225..ccfd4cf 100644 --- a/PlotLine/Services/FeatureRequestService.cs +++ b/PlotLine/Services/FeatureRequestService.cs @@ -10,7 +10,8 @@ public interface IFeatureRequestService Task GetForUserAsync(int featureRequestId, int userId); Task GetForAdminAsync(int featureRequestId); Task> ListForUserAsync(int userId); - Task> ListForAdminAsync(string? status); + Task> ListForAdminAsync(AdminFeatureRequestFilter filter); + Task GetAdminSummaryAsync(); Task UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model); Task AddUserReplyAsync(int featureRequestId, int userId, string messageBody); Task AddAdminReplyAsync(int featureRequestId, int adminUserId, string messageBody); @@ -38,8 +39,40 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque public Task> ListForUserAsync(int userId) => featureRequests.ListForUserAsync(userId); - public Task> ListForAdminAsync(string? status) => - featureRequests.ListForAdminAsync(FeatureRequestStatuses.IsValid(status) ? status : null); + public Task> ListForAdminAsync(AdminFeatureRequestFilter filter) + { + var sanitized = SanitizeFilter(filter); + return featureRequests.ListForAdminAsync( + sanitized.Status, + sanitized.AppArea, + sanitized.Importance, + sanitized.Search, + sanitized.Sort); + } + + public async Task 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 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 + }; + } } diff --git a/PlotLine/Sql/091_Phase15B_FeatureRequestWorkflow.sql b/PlotLine/Sql/091_Phase15B_FeatureRequestWorkflow.sql new file mode 100644 index 0000000..b1b45dc --- /dev/null +++ b/PlotLine/Sql/091_Phase15B_FeatureRequestWorkflow.sql @@ -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 diff --git a/PlotLine/ViewModels/FeatureRequestViewModels.cs b/PlotLine/ViewModels/FeatureRequestViewModels.cs index e06b6a1..b55e72f 100644 --- a/PlotLine/ViewModels/FeatureRequestViewModels.cs +++ b/PlotLine/ViewModels/FeatureRequestViewModels.cs @@ -50,7 +50,14 @@ public sealed class AdminFeatureRequestIndexViewModel { public IReadOnlyList 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 StatusOptions { get; set; } = FeatureRequestStatuses.All; + public IReadOnlyList AppAreas { get; set; } = FeatureRequestOptions.AppAreas; + public IReadOnlyList 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; +} diff --git a/PlotLine/Views/Admin/FeatureRequestDetails.cshtml b/PlotLine/Views/Admin/FeatureRequestDetails.cshtml index 3bcb002..3be6872 100644 --- a/PlotLine/Views/Admin/FeatureRequestDetails.cshtml +++ b/PlotLine/Views/Admin/FeatureRequestDetails.cshtml @@ -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 @@ + + @if (TempData["AdminMessage"] is string adminMessage) {
@adminMessage
@@ -27,7 +36,11 @@

Status

-

@request.Status

+

@StatusBadge(request.Status)

+
+
+

Conversation

+

@ConversationBadge(request.ConversationState)

App area

@@ -37,6 +50,14 @@

Importance

@(request.Importance ?? "Not set")

+
+

Created

+

@FormatDate(request.CreatedUtc)

+
+
+

Latest activity

+

@FormatNullableDate(request.LatestActivityUtc)

+

@request.Description

@@ -88,3 +109,39 @@ @section Scripts { } + +@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($"{HtmlEncoder.Default.Encode(status)}"); + } + + 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($"{HtmlEncoder.Default.Encode(state)}"); + } +} diff --git a/PlotLine/Views/Admin/FeatureRequests.cshtml b/PlotLine/Views/Admin/FeatureRequests.cshtml index f005b1a..a5e106c 100644 --- a/PlotLine/Views/Admin/FeatureRequests.cshtml +++ b/PlotLine/Views/Admin/FeatureRequests.cshtml @@ -1,4 +1,6 @@ @model AdminFeatureRequestIndexViewModel +@using Microsoft.AspNetCore.Html +@using System.Text.Encodings.Web @{ ViewData["Title"] = "Feature Requests"; } @@ -8,8 +10,24 @@

Admin

Feature Requests

-
- + + + +@if (TempData["AdminMessage"] is string adminMessage) +{ +
@adminMessage
+} + +
+ +
- +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Reset +
- - -@if (TempData["AdminMessage"] is string adminMessage) -{ -
@adminMessage
-} +
@if (!Model.Requests.Any()) { @@ -46,6 +95,12 @@ else User Status Area + Importance + Created + Latest Activity + Conversation + Messages + Latest message Updated @@ -56,8 +111,14 @@ else @request.Title @(request.UserDisplayName ?? request.UserEmail) - @request.Status + @StatusBadge(request.Status) @(request.AppArea ?? "Not set") + @(request.Importance ?? "Not set") + @FormatDate(request.CreatedUtc) + @FormatNullableDate(request.LatestActivityUtc) + @ConversationBadge(request.ConversationState) + @request.MessageCount.ToString("N0") + @Preview(request.LatestMessagePreview) @FormatDate(request.UpdatedUtc) Open @@ -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($"{HtmlEncoder.Default.Encode(status)}"); + } + + 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($"{HtmlEncoder.Default.Encode(state)}"); + } } diff --git a/PlotLine/Views/Admin/Index.cshtml b/PlotLine/Views/Admin/Index.cshtml index 544e535..ff6af14 100644 --- a/PlotLine/Views/Admin/Index.cshtml +++ b/PlotLine/Views/Admin/Index.cshtml @@ -1,5 +1,7 @@ +@model AdminDashboardViewModel @{ ViewData["Title"] = "Admin"; + var summary = Model.FeatureRequestSummary; }
@@ -10,6 +12,57 @@
+ + +
+ +
+ +
+

Feature request status

+
+ @foreach (var status in summary.Statuses) + { + + } +
+
+
diff --git a/PlotLine/Views/FeatureRequests/Create.cshtml b/PlotLine/Views/FeatureRequests/Create.cshtml index 7b0cbff..1ac38a8 100644 --- a/PlotLine/Views/FeatureRequests/Create.cshtml +++ b/PlotLine/Views/FeatureRequests/Create.cshtml @@ -11,6 +11,11 @@
+ +
diff --git a/PlotLine/Views/FeatureRequests/Details.cshtml b/PlotLine/Views/FeatureRequests/Details.cshtml index 5bdb6fa..7fe02d0 100644 --- a/PlotLine/Views/FeatureRequests/Details.cshtml +++ b/PlotLine/Views/FeatureRequests/Details.cshtml @@ -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 @@ + +

Request details

Status

-

@request.Status

+

@StatusBadge(request.Status)

+
+
+

Conversation

+

@ConversationBadge(request.ConversationState)

App area

@@ -33,7 +44,12 @@

Updated

@FormatDate(request.UpdatedUtc)

+
+

Latest activity

+

@FormatNullableDate(request.LatestActivityUtc)

+
+
@request.StatusExplanation

@request.Description

@@ -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($"{HtmlEncoder.Default.Encode(status)}"); + } + + 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($"{HtmlEncoder.Default.Encode(state)}"); + } } @section Scripts { diff --git a/PlotLine/Views/FeatureRequests/Index.cshtml b/PlotLine/Views/FeatureRequests/Index.cshtml index 59961e9..66ec1d6 100644 --- a/PlotLine/Views/FeatureRequests/Index.cshtml +++ b/PlotLine/Views/FeatureRequests/Index.cshtml @@ -1,4 +1,6 @@ @model FeatureRequestIndexViewModel +@using Microsoft.AspNetCore.Html +@using System.Text.Encodings.Web @{ ViewData["Title"] = "Feature Requests"; } @@ -14,6 +16,11 @@ + + @if (TempData["FeatureRequestMessage"] is string featureRequestMessage) {
@featureRequestMessage
@@ -35,12 +42,11 @@ else Title + Status + Latest Activity + Conversation App Area Importance - Status - Created - Updated - Latest Activity @@ -49,12 +55,11 @@ else { @request.Title + @StatusBadge(request.Status) + @FormatNullableDate(request.LatestActivityUtc) + @ConversationBadge(request.ConversationState) @(request.AppArea ?? "Not set") @(request.Importance ?? "Not set") - @request.Status - @FormatDate(request.CreatedUtc) - @FormatDate(request.UpdatedUtc) - @(request.LatestActivityUtc.HasValue ? FormatDate(request.LatestActivityUtc.Value) : "None yet") Open @@ -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($"{HtmlEncoder.Default.Encode(status)}"); + } + + 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($"{HtmlEncoder.Default.Encode(state)}"); + } }