Phase 13D: Series Dashboard Activity and Attention Widgets.
This commit is contained in:
parent
831bdf542d
commit
d872289e6f
@ -15,6 +15,7 @@ public interface IProjectRepository
|
|||||||
Task<Project?> GetForUserAsync(int projectId, int userId);
|
Task<Project?> GetForUserAsync(int projectId, int userId);
|
||||||
Task<Project?> GetArchivedForOwnerAsync(int projectId, int userId);
|
Task<Project?> GetArchivedForOwnerAsync(int projectId, int userId);
|
||||||
Task<ProjectDashboardMetrics> GetDashboardMetricsAsync(int projectId);
|
Task<ProjectDashboardMetrics> GetDashboardMetricsAsync(int projectId);
|
||||||
|
Task<IReadOnlyList<ProjectDashboardAttentionThread>> ListDashboardAttentionThreadsAsync(int projectId, int count);
|
||||||
Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync();
|
Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync();
|
||||||
Task<int> SaveAsync(Project project);
|
Task<int> SaveAsync(Project project);
|
||||||
Task<int> SaveAsync(Project project, int userId, string? genreMetricPresetKey);
|
Task<int> SaveAsync(Project project, int userId, string? genreMetricPresetKey);
|
||||||
@ -43,6 +44,7 @@ public interface IProjectCollaborationRepository
|
|||||||
public interface IProjectActivityRepository
|
public interface IProjectActivityRepository
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<ProjectActivity>> ListForProjectAsync(int projectId);
|
Task<IReadOnlyList<ProjectActivity>> ListForProjectAsync(int projectId);
|
||||||
|
Task<IReadOnlyList<ProjectActivity>> ListRecentForProjectAsync(int projectId, int count);
|
||||||
Task<long> RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null);
|
Task<long> RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,6 +220,7 @@ public interface IWarningRepository
|
|||||||
Task<ContinuityValidationSummary> ValidateBookAsync(int bookId);
|
Task<ContinuityValidationSummary> ValidateBookAsync(int bookId);
|
||||||
Task<ContinuityValidationSummary> ValidateSceneAsync(int sceneId);
|
Task<ContinuityValidationSummary> ValidateSceneAsync(int sceneId);
|
||||||
Task<IReadOnlyList<ContinuityWarning>> ListAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional);
|
Task<IReadOnlyList<ContinuityWarning>> ListAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional);
|
||||||
|
Task<IReadOnlyList<ContinuityWarning>> ListDashboardUnresolvedAsync(int projectId, int count);
|
||||||
Task<IReadOnlyList<ContinuityWarning>> ListBySceneAsync(int sceneId);
|
Task<IReadOnlyList<ContinuityWarning>> ListBySceneAsync(int sceneId);
|
||||||
Task<IReadOnlyList<SceneWarningCount>> GetSceneCountsAsync(int projectId, int? bookId);
|
Task<IReadOnlyList<SceneWarningCount>> GetSceneCountsAsync(int projectId, int? bookId);
|
||||||
Task DismissAsync(int continuityWarningId);
|
Task DismissAsync(int continuityWarningId);
|
||||||
@ -578,6 +581,50 @@ public sealed class WarningRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
return rows.ToList();
|
return rows.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ContinuityWarning>> ListDashboardUnresolvedAsync(int projectId, int count)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
var rows = await connection.QueryAsync<ContinuityWarning>(
|
||||||
|
"""
|
||||||
|
SELECT TOP (@Count)
|
||||||
|
cw.ContinuityWarningID,
|
||||||
|
cw.ValidationRunID,
|
||||||
|
cw.ProjectID,
|
||||||
|
cw.BookID,
|
||||||
|
b.BookTitle,
|
||||||
|
cw.ChapterID,
|
||||||
|
c.ChapterNumber,
|
||||||
|
c.ChapterTitle,
|
||||||
|
cw.SceneID,
|
||||||
|
s.SceneNumber,
|
||||||
|
s.SceneTitle,
|
||||||
|
cw.EntityType,
|
||||||
|
cw.EntityID,
|
||||||
|
cw.WarningTypeID,
|
||||||
|
wt.TypeName AS WarningTypeName,
|
||||||
|
cw.WarningSeverityID,
|
||||||
|
ws.SeverityName,
|
||||||
|
cw.Message,
|
||||||
|
cw.Details,
|
||||||
|
cw.IsDismissed,
|
||||||
|
cw.IsIntentional,
|
||||||
|
cw.CreatedDate,
|
||||||
|
cw.LastDetectedDate
|
||||||
|
FROM dbo.ContinuityWarnings cw
|
||||||
|
INNER JOIN dbo.WarningTypes wt ON wt.WarningTypeID = cw.WarningTypeID
|
||||||
|
INNER JOIN dbo.WarningSeverities ws ON ws.WarningSeverityID = cw.WarningSeverityID
|
||||||
|
LEFT JOIN dbo.Books b ON b.BookID = cw.BookID
|
||||||
|
LEFT JOIN dbo.Chapters c ON c.ChapterID = cw.ChapterID
|
||||||
|
LEFT JOIN dbo.Scenes s ON s.SceneID = cw.SceneID
|
||||||
|
WHERE cw.ProjectID = @ProjectID
|
||||||
|
AND cw.IsDismissed = 0
|
||||||
|
AND cw.IsIntentional = 0
|
||||||
|
ORDER BY ws.SortOrder DESC, cw.LastDetectedDate DESC, cw.ContinuityWarningID DESC;
|
||||||
|
""",
|
||||||
|
new { ProjectID = projectId, Count = count });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ContinuityWarning>> ListBySceneAsync(int sceneId)
|
public async Task<IReadOnlyList<ContinuityWarning>> ListBySceneAsync(int sceneId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
@ -1859,6 +1906,40 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
new { ProjectID = projectId });
|
new { ProjectID = projectId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ProjectDashboardAttentionThread>> ListDashboardAttentionThreadsAsync(int projectId, int count)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
var rows = await connection.QueryAsync<ProjectDashboardAttentionThread>(
|
||||||
|
"""
|
||||||
|
SELECT TOP (@Count)
|
||||||
|
pt.PlotThreadID,
|
||||||
|
pt.ThreadTitle,
|
||||||
|
pt.PlotLineID,
|
||||||
|
pl.PlotLineName,
|
||||||
|
pt.Importance,
|
||||||
|
ts.StatusName AS ThreadStatusName,
|
||||||
|
ts.IsOpenStatus,
|
||||||
|
ts.IsResolvedStatus
|
||||||
|
FROM dbo.PlotThreads pt
|
||||||
|
INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID
|
||||||
|
INNER JOIN dbo.ThreadStatuses ts ON ts.ThreadStatusID = pt.ThreadStatusID
|
||||||
|
WHERE pl.ProjectID = @ProjectID
|
||||||
|
AND pl.IsArchived = 0
|
||||||
|
AND pt.IsArchived = 0
|
||||||
|
AND (
|
||||||
|
ts.StatusName = N'Dormant'
|
||||||
|
OR (ts.IsOpenStatus = 1 AND pt.Importance >= 8)
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN ts.StatusName = N'Dormant' THEN 0 ELSE 1 END,
|
||||||
|
pt.Importance DESC,
|
||||||
|
pt.UpdatedDate DESC,
|
||||||
|
pt.PlotThreadID DESC;
|
||||||
|
""",
|
||||||
|
new { ProjectID = projectId, Count = count });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync()
|
public async Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync()
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
@ -2034,6 +2115,32 @@ public sealed class ProjectActivityRepository(ISqlConnectionFactory connectionFa
|
|||||||
return rows.ToList();
|
return rows.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ProjectActivity>> ListRecentForProjectAsync(int projectId, int count)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
var rows = await connection.QueryAsync<ProjectActivity>(
|
||||||
|
"""
|
||||||
|
SELECT TOP (@Count)
|
||||||
|
pa.ProjectActivityID,
|
||||||
|
pa.ProjectID,
|
||||||
|
pa.UserID,
|
||||||
|
u.DisplayName,
|
||||||
|
u.Email,
|
||||||
|
pa.ActivityType,
|
||||||
|
pa.EntityType,
|
||||||
|
pa.EntityID,
|
||||||
|
pa.EntityName,
|
||||||
|
pa.Description,
|
||||||
|
pa.CreatedDateUTC
|
||||||
|
FROM dbo.ProjectActivity pa
|
||||||
|
LEFT JOIN dbo.AppUser u ON u.UserID = pa.UserID
|
||||||
|
WHERE pa.ProjectID = @ProjectID
|
||||||
|
ORDER BY pa.CreatedDateUTC DESC, pa.ProjectActivityID DESC;
|
||||||
|
""",
|
||||||
|
new { ProjectID = projectId, Count = count });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<long> RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null)
|
public async Task<long> RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
@ -75,6 +75,18 @@ public sealed class ProjectDashboardMetrics
|
|||||||
public int ContinuityWarningCount { get; set; }
|
public int ContinuityWarningCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ProjectDashboardAttentionThread
|
||||||
|
{
|
||||||
|
public int PlotThreadID { get; set; }
|
||||||
|
public string ThreadTitle { get; set; } = string.Empty;
|
||||||
|
public int PlotLineID { get; set; }
|
||||||
|
public string PlotLineName { get; set; } = string.Empty;
|
||||||
|
public int Importance { get; set; }
|
||||||
|
public string ThreadStatusName { get; set; } = string.Empty;
|
||||||
|
public bool IsOpenStatus { get; set; }
|
||||||
|
public bool IsResolvedStatus { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ProjectHardDeleteResult
|
public sealed class ProjectHardDeleteResult
|
||||||
{
|
{
|
||||||
public bool Succeeded { get; init; }
|
public bool Succeeded { get; init; }
|
||||||
|
|||||||
@ -368,6 +368,9 @@ public interface IAcceptanceService
|
|||||||
public sealed class ProjectService(
|
public sealed class ProjectService(
|
||||||
IProjectRepository projects,
|
IProjectRepository projects,
|
||||||
IBookRepository books,
|
IBookRepository books,
|
||||||
|
IProjectActivityRepository projectActivity,
|
||||||
|
IWarningRepository warnings,
|
||||||
|
IWritingScheduleRepository writingSchedule,
|
||||||
IProjectActivityService activity,
|
IProjectActivityService activity,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IHardDeleteFileCleanupService fileCleanup,
|
IHardDeleteFileCleanupService fileCleanup,
|
||||||
@ -389,7 +392,13 @@ public sealed class ProjectService(
|
|||||||
|
|
||||||
public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId)
|
public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId)
|
||||||
{
|
{
|
||||||
var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null;
|
if (!currentUser.UserId.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = currentUser.UserId.Value;
|
||||||
|
var project = await projects.GetForUserAsync(projectId, userId);
|
||||||
if (project is null)
|
if (project is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@ -397,6 +406,10 @@ public sealed class ProjectService(
|
|||||||
|
|
||||||
var projectBooks = await books.ListByProjectAsync(projectId);
|
var projectBooks = await books.ListByProjectAsync(projectId);
|
||||||
var metrics = await projects.GetDashboardMetricsAsync(projectId);
|
var metrics = await projects.GetDashboardMetricsAsync(projectId);
|
||||||
|
var recentActivity = await projectActivity.ListRecentForProjectAsync(projectId, 10);
|
||||||
|
var dashboardWarnings = await warnings.ListDashboardUnresolvedAsync(projectId, 5);
|
||||||
|
var attentionThreads = await projects.ListDashboardAttentionThreadsAsync(projectId, 5);
|
||||||
|
var writingPlans = await writingSchedule.GetWritingPlanSummariesByUserAsync(userId, projectId);
|
||||||
|
|
||||||
return new ProjectDetailViewModel
|
return new ProjectDetailViewModel
|
||||||
{
|
{
|
||||||
@ -413,6 +426,25 @@ public sealed class ProjectService(
|
|||||||
ActivePlotThreadCount = metrics.ActivePlotThreadCount,
|
ActivePlotThreadCount = metrics.ActivePlotThreadCount,
|
||||||
ContinuityWarningCount = metrics.ContinuityWarningCount
|
ContinuityWarningCount = metrics.ContinuityWarningCount
|
||||||
},
|
},
|
||||||
|
RecentActivity = recentActivity.Select(ToDashboardActivityItem).ToList(),
|
||||||
|
NeedsAttention = new ProjectDashboardAttentionViewModel
|
||||||
|
{
|
||||||
|
Warnings = dashboardWarnings.Select(ToDashboardWarningItem).ToList(),
|
||||||
|
Threads = attentionThreads.Select(ToDashboardThreadItem).ToList(),
|
||||||
|
OverdueBooks = projectBooks
|
||||||
|
.Where(IsOverdueDashboardBook)
|
||||||
|
.OrderBy(book => book.TargetCompletionDate)
|
||||||
|
.ThenBy(book => book.BookNumber)
|
||||||
|
.Take(5)
|
||||||
|
.Select(book => new ProjectDashboardOverdueBookViewModel
|
||||||
|
{
|
||||||
|
BookID = book.BookID,
|
||||||
|
BookTitle = book.BookTitle,
|
||||||
|
TargetCompletionDate = book.TargetCompletionDate!.Value
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
ShowMissingSchedulePrompt = !writingPlans.Any(x => x.IsActive)
|
||||||
|
},
|
||||||
Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel
|
Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel
|
||||||
{
|
{
|
||||||
BookID = book.BookID,
|
BookID = book.BookID,
|
||||||
@ -431,6 +463,108 @@ public sealed class ProjectService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ProjectDashboardActivityItemViewModel ToDashboardActivityItem(ProjectActivity item)
|
||||||
|
{
|
||||||
|
return new ProjectDashboardActivityItemViewModel
|
||||||
|
{
|
||||||
|
ActivityType = item.ActivityType,
|
||||||
|
EntityType = item.EntityType,
|
||||||
|
EntityName = item.EntityName,
|
||||||
|
Description = item.Description,
|
||||||
|
UserLabel = item.UserLabel,
|
||||||
|
TimeLabel = RelativeTimeLabel(item.CreatedDateUTC),
|
||||||
|
IconLabel = DashboardIconLabel(item.EntityType, item.ActivityType)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProjectDashboardWarningItemViewModel ToDashboardWarningItem(ContinuityWarning warning)
|
||||||
|
{
|
||||||
|
return new ProjectDashboardWarningItemViewModel
|
||||||
|
{
|
||||||
|
SeverityName = warning.SeverityName,
|
||||||
|
WarningTypeName = warning.WarningTypeName,
|
||||||
|
Message = warning.Message,
|
||||||
|
LocationLabel = DashboardWarningLocation(warning)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProjectDashboardThreadItemViewModel ToDashboardThreadItem(ProjectDashboardAttentionThread thread)
|
||||||
|
{
|
||||||
|
return new ProjectDashboardThreadItemViewModel
|
||||||
|
{
|
||||||
|
PlotThreadID = thread.PlotThreadID,
|
||||||
|
ThreadTitle = thread.ThreadTitle,
|
||||||
|
PlotLineName = thread.PlotLineName,
|
||||||
|
ThreadStatusName = thread.ThreadStatusName,
|
||||||
|
Importance = thread.Importance,
|
||||||
|
AttentionLabel = string.Equals(thread.ThreadStatusName, "Dormant", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "Dormant"
|
||||||
|
: "High importance open"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOverdueDashboardBook(Book book)
|
||||||
|
{
|
||||||
|
return book.TargetCompletionDate.HasValue
|
||||||
|
&& book.TargetCompletionDate.Value.Date < DateTime.Today
|
||||||
|
&& !string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(book.Status, BookStatuses.Archived, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DashboardIconLabel(string entityType, string activityType)
|
||||||
|
{
|
||||||
|
var source = string.IsNullOrWhiteSpace(entityType) ? activityType : entityType;
|
||||||
|
return string.IsNullOrWhiteSpace(source) ? "A" : source.Trim()[0].ToString().ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DashboardWarningLocation(ContinuityWarning warning)
|
||||||
|
{
|
||||||
|
if (warning.SceneNumber.HasValue)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(warning.SceneTitle)
|
||||||
|
? $"Scene {warning.SceneNumber.Value:g}"
|
||||||
|
: $"Scene {warning.SceneNumber.Value:g}: {warning.SceneTitle}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warning.ChapterNumber.HasValue)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(warning.ChapterTitle)
|
||||||
|
? $"Chapter {warning.ChapterNumber.Value:g}"
|
||||||
|
: $"Chapter {warning.ChapterNumber.Value:g}: {warning.ChapterTitle}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return warning.BookTitle ?? warning.EntityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RelativeTimeLabel(DateTime createdDateUtc)
|
||||||
|
{
|
||||||
|
var elapsed = DateTime.UtcNow - DateTime.SpecifyKind(createdDateUtc, DateTimeKind.Utc);
|
||||||
|
if (elapsed.TotalMinutes < 1)
|
||||||
|
{
|
||||||
|
return "Just now";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed.TotalMinutes < 60)
|
||||||
|
{
|
||||||
|
var minutes = Math.Max(1, (int)Math.Floor(elapsed.TotalMinutes));
|
||||||
|
return minutes == 1 ? "1 minute ago" : $"{minutes} minutes ago";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed.TotalHours < 24)
|
||||||
|
{
|
||||||
|
var hours = Math.Max(1, (int)Math.Floor(elapsed.TotalHours));
|
||||||
|
return hours == 1 ? "1 hour ago" : $"{hours} hours ago";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed.TotalDays < 7)
|
||||||
|
{
|
||||||
|
var days = Math.Max(1, (int)Math.Floor(elapsed.TotalDays));
|
||||||
|
return days == 1 ? "Yesterday" : $"{days} days ago";
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdDateUtc.ToString("dd MMM yyyy");
|
||||||
|
}
|
||||||
|
|
||||||
public Task<Project?> GetArchivedOwnerProjectAsync(int projectId)
|
public Task<Project?> GetArchivedOwnerProjectAsync(int projectId)
|
||||||
{
|
{
|
||||||
return currentUser.UserId.HasValue
|
return currentUser.UserId.HasValue
|
||||||
|
|||||||
@ -31,6 +31,8 @@ public sealed class ProjectDetailViewModel
|
|||||||
{
|
{
|
||||||
public Project Project { get; set; } = new();
|
public Project Project { get; set; } = new();
|
||||||
public ProjectDashboardSummaryViewModel Summary { get; set; } = new();
|
public ProjectDashboardSummaryViewModel Summary { get; set; } = new();
|
||||||
|
public IReadOnlyList<ProjectDashboardActivityItemViewModel> RecentActivity { get; set; } = [];
|
||||||
|
public ProjectDashboardAttentionViewModel NeedsAttention { get; set; } = new();
|
||||||
public IReadOnlyList<ProjectDashboardBookCardViewModel> Books { get; set; } = [];
|
public IReadOnlyList<ProjectDashboardBookCardViewModel> Books { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +69,52 @@ public sealed class ProjectDashboardBookCardViewModel
|
|||||||
: $"{CurrentWordCount:N0} words";
|
: $"{CurrentWordCount:N0} words";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ProjectDashboardActivityItemViewModel
|
||||||
|
{
|
||||||
|
public string ActivityType { get; set; } = string.Empty;
|
||||||
|
public string EntityType { get; set; } = string.Empty;
|
||||||
|
public string? EntityName { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string UserLabel { get; set; } = string.Empty;
|
||||||
|
public string TimeLabel { get; set; } = string.Empty;
|
||||||
|
public string IconLabel { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProjectDashboardAttentionViewModel
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ProjectDashboardWarningItemViewModel> Warnings { get; set; } = [];
|
||||||
|
public IReadOnlyList<ProjectDashboardThreadItemViewModel> Threads { get; set; } = [];
|
||||||
|
public IReadOnlyList<ProjectDashboardOverdueBookViewModel> OverdueBooks { get; set; } = [];
|
||||||
|
public bool ShowMissingSchedulePrompt { get; set; }
|
||||||
|
|
||||||
|
public bool HasItems => Warnings.Any() || Threads.Any() || OverdueBooks.Any() || ShowMissingSchedulePrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProjectDashboardWarningItemViewModel
|
||||||
|
{
|
||||||
|
public string SeverityName { get; set; } = string.Empty;
|
||||||
|
public string WarningTypeName { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public string? LocationLabel { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProjectDashboardThreadItemViewModel
|
||||||
|
{
|
||||||
|
public int PlotThreadID { get; set; }
|
||||||
|
public string ThreadTitle { get; set; } = string.Empty;
|
||||||
|
public string PlotLineName { get; set; } = string.Empty;
|
||||||
|
public string ThreadStatusName { get; set; } = string.Empty;
|
||||||
|
public int Importance { get; set; }
|
||||||
|
public string AttentionLabel { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProjectDashboardOverdueBookViewModel
|
||||||
|
{
|
||||||
|
public int BookID { get; set; }
|
||||||
|
public string BookTitle { get; set; } = string.Empty;
|
||||||
|
public DateTime TargetCompletionDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ProjectCollaboratorsViewModel
|
public sealed class ProjectCollaboratorsViewModel
|
||||||
{
|
{
|
||||||
public Project Project { get; set; } = new();
|
public Project Project { get; set; } = new();
|
||||||
|
|||||||
@ -86,6 +86,141 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-4" aria-label="Dashboard attention">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-12 col-xl-7">
|
||||||
|
<article class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
|
||||||
|
<h2 class="h5 mb-0">Recent Activity</h2>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" asp-action="Activity" asp-route-id="@Model.Project.ProjectID">View all</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!Model.RecentActivity.Any())
|
||||||
|
{
|
||||||
|
<p class="muted mb-0">No project activity has been recorded yet.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@foreach (var item in Model.RecentActivity)
|
||||||
|
{
|
||||||
|
<div class="list-group-item px-0">
|
||||||
|
<div class="d-flex gap-3 align-items-start">
|
||||||
|
<span class="status-pill flex-shrink-0">@item.IconLabel</span>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-2">
|
||||||
|
<strong>@item.ActivityType @item.EntityType</strong>
|
||||||
|
<span class="small text-muted">@item.TimeLabel</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(item.EntityName))
|
||||||
|
{
|
||||||
|
<p class="mb-1">@item.EntityName</p>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(item.Description))
|
||||||
|
{
|
||||||
|
<p class="muted mb-1">@item.Description</p>
|
||||||
|
}
|
||||||
|
<p class="small text-muted mb-0">@item.UserLabel</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xl-5">
|
||||||
|
<article class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h5 mb-3">Needs Attention</h2>
|
||||||
|
|
||||||
|
@if (!Model.NeedsAttention.HasItems)
|
||||||
|
{
|
||||||
|
<p class="muted mb-0">Nothing needs attention right now.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (Model.NeedsAttention.Warnings.Any())
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
|
||||||
|
<h3 class="h6 mb-0">Continuity warnings</h3>
|
||||||
|
<a class="small" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Review</a>
|
||||||
|
</div>
|
||||||
|
@foreach (var warning in Model.NeedsAttention.Warnings)
|
||||||
|
{
|
||||||
|
<div class="border-top py-2">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<span class="status-pill">@warning.SeverityName</span>
|
||||||
|
<strong class="small">@warning.WarningTypeName</strong>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1">@warning.Message</p>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(warning.LocationLabel))
|
||||||
|
{
|
||||||
|
<p class="small text-muted mb-0">@warning.LocationLabel</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.NeedsAttention.Threads.Any())
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
|
||||||
|
<h3 class="h6 mb-0">Plot threads</h3>
|
||||||
|
<a class="small" asp-controller="PlotThreads" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Review</a>
|
||||||
|
</div>
|
||||||
|
@foreach (var thread in Model.NeedsAttention.Threads)
|
||||||
|
{
|
||||||
|
<div class="border-top py-2">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<span class="status-pill">@thread.AttentionLabel</span>
|
||||||
|
<span class="small text-muted">Importance @thread.Importance</span>
|
||||||
|
</div>
|
||||||
|
<strong>@thread.ThreadTitle</strong>
|
||||||
|
<p class="small text-muted mb-0">@thread.PlotLineName / @thread.ThreadStatusName</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.NeedsAttention.ShowMissingSchedulePrompt)
|
||||||
|
{
|
||||||
|
<div class="border-top py-2">
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-3">
|
||||||
|
<div>
|
||||||
|
<strong>No active writing schedule</strong>
|
||||||
|
<p class="small text-muted mb-0">Create a schedule when you are ready to plan writing sessions.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" asp-controller="Writer" asp-action="ScheduleSetup" asp-route-projectId="@Model.Project.ProjectID">Set up</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.NeedsAttention.OverdueBooks.Any())
|
||||||
|
{
|
||||||
|
<div class="mt-3">
|
||||||
|
<h3 class="h6 mb-2">Overdue target dates</h3>
|
||||||
|
@foreach (var book in Model.NeedsAttention.OverdueBooks)
|
||||||
|
{
|
||||||
|
<div class="border-top py-2">
|
||||||
|
<a asp-controller="Books" asp-action="Details" asp-route-id="@book.BookID">@book.BookTitle</a>
|
||||||
|
<p class="small text-muted mb-0">Target completion was @book.TargetCompletionDate.ToString("dd MMM yyyy")</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section aria-label="Books">
|
<section aria-label="Books">
|
||||||
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
|
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
|
||||||
<h2 class="h4 mb-0">Books <help-icon key="projects.books" /></h2>
|
<h2 class="h4 mb-0">Books <help-icon key="projects.books" /></h2>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user