Phase 15B - Feature Request Workflow, Filtering and Admin Productivity
This commit is contained in:
parent
955fcb9c0f
commit
ac14776e45
@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
156
PlotLine/Sql/091_Phase15B_FeatureRequestWorkflow.sql
Normal file
156
PlotLine/Sql/091_Phase15B_FeatureRequestWorkflow.sql
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if (TempData["AdminMessage"] is string adminMessage)
|
||||
<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)
|
||||
{
|
||||
<div class="alert alert-info">@adminMessage</div>
|
||||
<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>
|
||||
</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>");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user