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("")]
|
||||||
[HttpGet("index")]
|
[HttpGet("index")]
|
||||||
public IActionResult Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
return View();
|
return View(new AdminDashboardViewModel
|
||||||
|
{
|
||||||
|
FeatureRequestSummary = await featureRequests.GetAdminSummaryAsync()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("theme-audit")]
|
[HttpGet("theme-audit")]
|
||||||
@ -26,13 +29,25 @@ public sealed class AdminController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("feature-requests")]
|
[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
|
return View(new AdminFeatureRequestIndexViewModel
|
||||||
{
|
{
|
||||||
Status = selectedStatus,
|
Status = filter.Status,
|
||||||
Requests = await featureRequests.ListForAdminAsync(selectedStatus)
|
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?> GetForUserAsync(int featureRequestId, int userId);
|
||||||
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
|
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
|
||||||
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
|
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?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate);
|
||||||
Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes);
|
Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes);
|
||||||
Task<FeatureRequestMessage?> AddMessageAsync(int featureRequestId, int? userId, bool isAdmin, string messageBody);
|
Task<FeatureRequestMessage?> AddMessageAsync(int featureRequestId, int? userId, bool isAdmin, string messageBody);
|
||||||
@ -56,12 +56,12 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
|
|||||||
return rows.ToList();
|
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();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
var rows = await connection.QueryAsync<FeatureRequest>(
|
var rows = await connection.QueryAsync<FeatureRequest>(
|
||||||
"dbo.FeatureRequest_ListForAdmin",
|
"dbo.FeatureRequest_ListForAdmin",
|
||||||
new { Status = status },
|
new { Status = status, AppArea = appArea, Importance = importance, Search = search, Sort = sort },
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
return rows.ToList();
|
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 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
|
public static class FeatureRequestOptions
|
||||||
@ -69,6 +101,50 @@ public sealed class FeatureRequest
|
|||||||
public DateTime? ClosedUtc { get; set; }
|
public DateTime? ClosedUtc { get; set; }
|
||||||
public DateTime? ShippedUtc { get; set; }
|
public DateTime? ShippedUtc { get; set; }
|
||||||
public DateTime? LatestActivityUtc { 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
|
public sealed class FeatureRequestMessage
|
||||||
|
|||||||
@ -10,7 +10,8 @@ public interface IFeatureRequestService
|
|||||||
Task<FeatureRequest?> GetForUserAsync(int featureRequestId, int userId);
|
Task<FeatureRequest?> GetForUserAsync(int featureRequestId, int userId);
|
||||||
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
|
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
|
||||||
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
|
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> UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model);
|
||||||
Task<bool> AddUserReplyAsync(int featureRequestId, int userId, string messageBody);
|
Task<bool> AddUserReplyAsync(int featureRequestId, int userId, string messageBody);
|
||||||
Task<bool> AddAdminReplyAsync(int featureRequestId, int adminUserId, 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) =>
|
public Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId) =>
|
||||||
featureRequests.ListForUserAsync(userId);
|
featureRequests.ListForUserAsync(userId);
|
||||||
|
|
||||||
public Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status) =>
|
public Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(AdminFeatureRequestFilter filter)
|
||||||
featureRequests.ListForAdminAsync(FeatureRequestStatuses.IsValid(status) ? status : null);
|
{
|
||||||
|
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)
|
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 Clean(string value) => value.Trim();
|
||||||
|
|
||||||
private static string? CleanNullable(string? value) => string.IsNullOrWhiteSpace(value) ? null : 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 IReadOnlyList<FeatureRequest> Requests { get; set; } = [];
|
||||||
public string? Status { 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> 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
|
public sealed class AdminFeatureRequestUpdateViewModel
|
||||||
@ -60,3 +67,17 @@ public sealed class AdminFeatureRequestUpdateViewModel
|
|||||||
public bool IsPublicCandidate { get; set; }
|
public bool IsPublicCandidate { get; set; }
|
||||||
public string? AdminNotes { 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
|
@model FeatureRequestDetailsViewModel
|
||||||
|
@using Microsoft.AspNetCore.Html
|
||||||
|
@using System.Text.Encodings.Web
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = Model.Request.Title;
|
ViewData["Title"] = Model.Request.Title;
|
||||||
var request = Model.Request;
|
var request = Model.Request;
|
||||||
@ -15,6 +17,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</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)
|
@if (TempData["AdminMessage"] is string adminMessage)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">@adminMessage</div>
|
<div class="alert alert-info">@adminMessage</div>
|
||||||
@ -27,7 +36,11 @@
|
|||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<p class="eyebrow">Status</p>
|
<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>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<p class="eyebrow">App area</p>
|
<p class="eyebrow">App area</p>
|
||||||
@ -37,6 +50,14 @@
|
|||||||
<p class="eyebrow">Importance</p>
|
<p class="eyebrow">Importance</p>
|
||||||
<p>@(request.Importance ?? "Not set")</p>
|
<p>@(request.Importance ?? "Not set")</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p style="white-space: pre-wrap">@request.Description</p>
|
<p style="white-space: pre-wrap">@request.Description</p>
|
||||||
</section>
|
</section>
|
||||||
@ -88,3 +109,39 @@
|
|||||||
@section Scripts {
|
@section Scripts {
|
||||||
<partial name="_ValidationScriptsPartial" />
|
<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
|
@model AdminFeatureRequestIndexViewModel
|
||||||
|
@using Microsoft.AspNetCore.Html
|
||||||
|
@using System.Text.Encodings.Web
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Feature Requests";
|
ViewData["Title"] = "Feature Requests";
|
||||||
}
|
}
|
||||||
@ -8,8 +10,24 @@
|
|||||||
<p class="eyebrow">Admin</p>
|
<p class="eyebrow">Admin</p>
|
||||||
<h1>Feature Requests</h1>
|
<h1>Feature Requests</h1>
|
||||||
</div>
|
</div>
|
||||||
<form method="get" class="d-flex gap-2 align-items-end">
|
<div class="button-row">
|
||||||
<div>
|
<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>
|
<label class="form-label" for="status">Status</label>
|
||||||
<select class="form-select" id="status" name="status">
|
<select class="form-select" id="status" name="status">
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
@ -19,14 +37,45 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-outline-primary" type="submit">Filter</button>
|
<div class="col-md-2">
|
||||||
</form>
|
<label class="form-label" for="appArea">App Area</label>
|
||||||
</div>
|
<select class="form-select" id="appArea" name="appArea">
|
||||||
|
<option value="">All areas</option>
|
||||||
@if (TempData["AdminMessage"] is string adminMessage)
|
@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())
|
@if (!Model.Requests.Any())
|
||||||
{
|
{
|
||||||
@ -46,6 +95,12 @@ else
|
|||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Area</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>Updated</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -56,8 +111,14 @@ else
|
|||||||
<tr>
|
<tr>
|
||||||
<td>@request.Title</td>
|
<td>@request.Title</td>
|
||||||
<td>@(request.UserDisplayName ?? request.UserEmail)</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.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>@FormatDate(request.UpdatedUtc)</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a class="btn btn-outline-primary btn-sm" asp-action="FeatureRequestDetails" asp-route-id="@request.FeatureRequestID">Open</a>
|
<a class="btn btn-outline-primary btn-sm" asp-action="FeatureRequestDetails" asp-route-id="@request.FeatureRequestID">Open</a>
|
||||||
@ -72,4 +133,47 @@ else
|
|||||||
|
|
||||||
@functions {
|
@functions {
|
||||||
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
|
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";
|
ViewData["Title"] = "Admin";
|
||||||
|
var summary = Model.FeatureRequestSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="page-heading compact">
|
<div class="page-heading compact">
|
||||||
@ -10,6 +12,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<section class="project-card-grid">
|
||||||
<article class="project-card">
|
<article class="project-card">
|
||||||
<div class="project-card__body">
|
<div class="project-card__body">
|
||||||
|
|||||||
@ -11,6 +11,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<section class="edit-panel">
|
||||||
<form asp-action="Create" method="post">
|
<form asp-action="Create" method="post">
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
@model FeatureRequestDetailsViewModel
|
@model FeatureRequestDetailsViewModel
|
||||||
|
@using Microsoft.AspNetCore.Html
|
||||||
|
@using System.Text.Encodings.Web
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = Model.Request.Title;
|
ViewData["Title"] = Model.Request.Title;
|
||||||
var request = Model.Request;
|
var request = Model.Request;
|
||||||
@ -14,12 +16,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<section class="edit-panel">
|
||||||
<h2>Request details</h2>
|
<h2>Request details</h2>
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<p class="eyebrow">Status</p>
|
<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>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<p class="eyebrow">App area</p>
|
<p class="eyebrow">App area</p>
|
||||||
@ -33,7 +44,12 @@
|
|||||||
<p class="eyebrow">Updated</p>
|
<p class="eyebrow">Updated</p>
|
||||||
<p>@FormatDate(request.UpdatedUtc)</p>
|
<p>@FormatDate(request.UpdatedUtc)</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<p class="eyebrow">Latest activity</p>
|
||||||
|
<p>@FormatNullableDate(request.LatestActivityUtc)</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info">@request.StatusExplanation</div>
|
||||||
<p style="white-space: pre-wrap">@request.Description</p>
|
<p style="white-space: pre-wrap">@request.Description</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -53,6 +69,38 @@
|
|||||||
|
|
||||||
@functions {
|
@functions {
|
||||||
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
|
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 {
|
@section Scripts {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
@model FeatureRequestIndexViewModel
|
@model FeatureRequestIndexViewModel
|
||||||
|
@using Microsoft.AspNetCore.Html
|
||||||
|
@using System.Text.Encodings.Web
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Feature Requests";
|
ViewData["Title"] = "Feature Requests";
|
||||||
}
|
}
|
||||||
@ -14,6 +16,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</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)
|
@if (TempData["FeatureRequestMessage"] is string featureRequestMessage)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">@featureRequestMessage</div>
|
<div class="alert alert-info">@featureRequestMessage</div>
|
||||||
@ -35,12 +42,11 @@ else
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Latest Activity</th>
|
||||||
|
<th>Conversation</th>
|
||||||
<th>App Area</th>
|
<th>App Area</th>
|
||||||
<th>Importance</th>
|
<th>Importance</th>
|
||||||
<th>Status</th>
|
|
||||||
<th>Created</th>
|
|
||||||
<th>Updated</th>
|
|
||||||
<th>Latest Activity</th>
|
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -49,12 +55,11 @@ else
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@request.Title</td>
|
<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.AppArea ?? "Not set")</td>
|
||||||
<td>@(request.Importance ?? "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">
|
<td class="text-end">
|
||||||
<a class="btn btn-outline-primary btn-sm" asp-action="Details" asp-route-id="@request.FeatureRequestID">Open</a>
|
<a class="btn btn-outline-primary btn-sm" asp-action="Details" asp-route-id="@request.FeatureRequestID">Open</a>
|
||||||
</td>
|
</td>
|
||||||
@ -68,4 +73,36 @@ else
|
|||||||
|
|
||||||
@functions {
|
@functions {
|
||||||
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
|
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