Phase 13C: Project Dashboard Redesign.
This commit is contained in:
parent
8a4b016526
commit
90d1bb48d6
@ -14,6 +14,7 @@ public interface IProjectRepository
|
||||
Task<Project?> GetAsync(int projectId);
|
||||
Task<Project?> GetForUserAsync(int projectId, int userId);
|
||||
Task<Project?> GetArchivedForOwnerAsync(int projectId, int userId);
|
||||
Task<ProjectDashboardMetrics> GetDashboardMetricsAsync(int projectId);
|
||||
Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync();
|
||||
Task<int> SaveAsync(Project project);
|
||||
Task<int> SaveAsync(Project project, int userId, string? genreMetricPresetKey);
|
||||
@ -1829,6 +1830,35 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
|
||||
return await connection.QuerySingleOrDefaultAsync<Project>("dbo.Project_GetArchivedForOwner", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<ProjectDashboardMetrics> GetDashboardMetricsAsync(int projectId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<ProjectDashboardMetrics>(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(1) FROM dbo.Characters WHERE ProjectID = @ProjectID AND IsArchived = 0) AS CharacterCount,
|
||||
(SELECT COUNT(1) FROM dbo.Locations WHERE ProjectID = @ProjectID AND IsArchived = 0) AS LocationCount,
|
||||
(
|
||||
SELECT COUNT(1)
|
||||
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.IsOpenStatus = 1
|
||||
) AS ActivePlotThreadCount,
|
||||
(
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.ContinuityWarnings
|
||||
WHERE ProjectID = @ProjectID
|
||||
AND IsDismissed = 0
|
||||
AND IsIntentional = 0
|
||||
) AS ContinuityWarningCount;
|
||||
""",
|
||||
new { ProjectID = projectId });
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync()
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -67,6 +67,14 @@ public sealed class ProjectActivity
|
||||
: "System";
|
||||
}
|
||||
|
||||
public sealed class ProjectDashboardMetrics
|
||||
{
|
||||
public int CharacterCount { get; set; }
|
||||
public int LocationCount { get; set; }
|
||||
public int ActivePlotThreadCount { get; set; }
|
||||
public int ContinuityWarningCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProjectHardDeleteResult
|
||||
{
|
||||
public bool Succeeded { get; init; }
|
||||
|
||||
@ -395,10 +395,39 @@ public sealed class ProjectService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var projectBooks = await books.ListByProjectAsync(projectId);
|
||||
var metrics = await projects.GetDashboardMetricsAsync(projectId);
|
||||
|
||||
return new ProjectDetailViewModel
|
||||
{
|
||||
Project = project,
|
||||
Books = await books.ListByProjectAsync(projectId)
|
||||
Summary = new ProjectDashboardSummaryViewModel
|
||||
{
|
||||
TotalBooks = projectBooks.Count,
|
||||
PublishedBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)),
|
||||
DraftingBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Drafting, StringComparison.OrdinalIgnoreCase)),
|
||||
PlanningBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Planning, StringComparison.OrdinalIgnoreCase)),
|
||||
TotalWords = projectBooks.Sum(book => book.CurrentWordCount),
|
||||
CharacterCount = metrics.CharacterCount,
|
||||
LocationCount = metrics.LocationCount,
|
||||
ActivePlotThreadCount = metrics.ActivePlotThreadCount,
|
||||
ContinuityWarningCount = metrics.ContinuityWarningCount
|
||||
},
|
||||
Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel
|
||||
{
|
||||
BookID = book.BookID,
|
||||
BookNumber = book.BookNumber,
|
||||
BookTitle = book.BookTitle,
|
||||
Subtitle = book.Subtitle,
|
||||
Status = book.Status,
|
||||
CurrentWordCount = book.CurrentWordCount,
|
||||
TargetWordCount = book.TargetWordCount,
|
||||
ProgressPercentage = book.ProgressPercentage,
|
||||
ChapterCount = book.ChapterCount,
|
||||
SceneCount = book.SceneCount,
|
||||
UpdatedDate = book.UpdatedDate,
|
||||
CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -30,7 +30,41 @@ public sealed class ProjectIndexViewModel
|
||||
public sealed class ProjectDetailViewModel
|
||||
{
|
||||
public Project Project { get; set; } = new();
|
||||
public IReadOnlyList<Book> Books { get; set; } = [];
|
||||
public ProjectDashboardSummaryViewModel Summary { get; set; } = new();
|
||||
public IReadOnlyList<ProjectDashboardBookCardViewModel> Books { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ProjectDashboardSummaryViewModel
|
||||
{
|
||||
public int TotalBooks { get; set; }
|
||||
public int PublishedBooks { get; set; }
|
||||
public int DraftingBooks { get; set; }
|
||||
public int PlanningBooks { get; set; }
|
||||
public int TotalWords { get; set; }
|
||||
public int CharacterCount { get; set; }
|
||||
public int LocationCount { get; set; }
|
||||
public int ActivePlotThreadCount { get; set; }
|
||||
public int ContinuityWarningCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProjectDashboardBookCardViewModel
|
||||
{
|
||||
public int BookID { get; set; }
|
||||
public int BookNumber { get; set; }
|
||||
public string BookTitle { get; set; } = string.Empty;
|
||||
public string? Subtitle { get; set; }
|
||||
public string Status { get; set; } = BookStatuses.Planning;
|
||||
public int CurrentWordCount { get; set; }
|
||||
public int? TargetWordCount { get; set; }
|
||||
public decimal? ProgressPercentage { get; set; }
|
||||
public int ChapterCount { get; set; }
|
||||
public int SceneCount { get; set; }
|
||||
public DateTime UpdatedDate { get; set; }
|
||||
public string CoverThumbnailPath { get; set; } = "/images/placeholders/book-cover.svg";
|
||||
|
||||
public string WordCountLabel => TargetWordCount is > 0
|
||||
? $"{CurrentWordCount:N0} / {TargetWordCount.Value:N0} words"
|
||||
: $"{CurrentWordCount:N0} words";
|
||||
}
|
||||
|
||||
public sealed class ProjectCollaboratorsViewModel
|
||||
|
||||
@ -2,6 +2,19 @@
|
||||
@{
|
||||
ViewData["Title"] = Model.Project.ProjectName;
|
||||
ViewData["ProjectSection"] = "Overview";
|
||||
|
||||
var summaryCards = new[]
|
||||
{
|
||||
("Total Books", Model.Summary.TotalBooks.ToString("N0")),
|
||||
("Published Books", Model.Summary.PublishedBooks.ToString("N0")),
|
||||
("Drafting Books", Model.Summary.DraftingBooks.ToString("N0")),
|
||||
("Planning Books", Model.Summary.PlanningBooks.ToString("N0")),
|
||||
("Total Words", Model.Summary.TotalWords.ToString("N0")),
|
||||
("Characters", Model.Summary.CharacterCount.ToString("N0")),
|
||||
("Locations", Model.Summary.LocationCount.ToString("N0")),
|
||||
("Active Plot Threads", Model.Summary.ActivePlotThreadCount.ToString("N0")),
|
||||
("Continuity Warnings", Model.Summary.ContinuityWarningCount.ToString("N0"))
|
||||
};
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
@ -13,7 +26,7 @@
|
||||
|
||||
<div class="page-heading pw-page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Project / Series</p>
|
||||
<p class="eyebrow">Series Dashboard</p>
|
||||
<h1>@Model.Project.ProjectName <help-icon key="projectDashboard.overview" /></h1>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Project.Description))
|
||||
{
|
||||
@ -28,25 +41,25 @@
|
||||
Actions
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a class="dropdown-item" asp-action="Activity" asp-route-id="@Model.Project.ProjectID">Activity</a>
|
||||
@if (Model.Project.IsOwner)
|
||||
{
|
||||
<a class="dropdown-item" asp-controller="ProjectCollaborators" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Collaborators</a>
|
||||
}
|
||||
@if (Model.Project.IsOwner)
|
||||
{
|
||||
<a class="dropdown-item" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@Model.Project.ProjectID">Download Project Backup</a>
|
||||
}
|
||||
<a class="dropdown-item" asp-controller="ProjectRestorePoints" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Restore Points</a>
|
||||
<a class="dropdown-item" asp-controller="Acceptance" asp-action="Phase1" asp-route-projectId="@Model.Project.ProjectID">Phase 1 Checklist</a>
|
||||
@if (Model.Project.IsOwner)
|
||||
{
|
||||
<div class="dropdown-divider"></div>
|
||||
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project? Archived projects are hidden from your active workspace but remain stored within PlotDirector. Export your project first if you wish to maintain an external backup.">
|
||||
<input type="hidden" name="id" value="@Model.Project.ProjectID" />
|
||||
<button class="dropdown-item text-danger" type="submit">Archive Project</button>
|
||||
</form>
|
||||
}
|
||||
<a class="dropdown-item" asp-action="Activity" asp-route-id="@Model.Project.ProjectID">Activity</a>
|
||||
@if (Model.Project.IsOwner)
|
||||
{
|
||||
<a class="dropdown-item" asp-controller="ProjectCollaborators" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Collaborators</a>
|
||||
}
|
||||
@if (Model.Project.IsOwner)
|
||||
{
|
||||
<a class="dropdown-item" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@Model.Project.ProjectID">Download Project Backup</a>
|
||||
}
|
||||
<a class="dropdown-item" asp-controller="ProjectRestorePoints" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Restore Points</a>
|
||||
<a class="dropdown-item" asp-controller="Acceptance" asp-action="Phase1" asp-route-projectId="@Model.Project.ProjectID">Phase 1 Checklist</a>
|
||||
@if (Model.Project.IsOwner)
|
||||
{
|
||||
<div class="dropdown-divider"></div>
|
||||
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project? Archived projects are hidden from your active workspace but remain stored within PlotDirector. Export your project first if you wish to maintain an external backup.">
|
||||
<input type="hidden" name="id" value="@Model.Project.ProjectID" />
|
||||
<button class="dropdown-item text-danger" type="submit">Archive Project</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -57,43 +70,95 @@
|
||||
<div class="alert alert-warning">@projectExportMessage</div>
|
||||
}
|
||||
|
||||
<section class="list-section">
|
||||
<h2>Books <help-icon key="projects.books" /></h2>
|
||||
<section class="mb-4" aria-label="Series summary">
|
||||
<div class="row g-3">
|
||||
@foreach (var card in summaryCards)
|
||||
{
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body py-3">
|
||||
<p class="eyebrow mb-2">@card.Item1</p>
|
||||
<strong class="fs-3 lh-1">@card.Item2</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Books">
|
||||
<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>
|
||||
<a class="btn btn-primary btn-sm" asp-controller="Books" asp-action="Create" asp-route-projectId="@Model.Project.ProjectID">Add book</a>
|
||||
</div>
|
||||
|
||||
@if (!Model.Books.Any())
|
||||
{
|
||||
<p class="muted">No books yet. Add the first book to begin the hierarchy.</p>
|
||||
<div class="empty-panel">
|
||||
<h2>No books yet</h2>
|
||||
<p class="muted mb-0">Add the first book to begin shaping this series.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Book</th>
|
||||
<th>Description</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var book in Model.Books)
|
||||
{
|
||||
<tr>
|
||||
<td class="narrow">@book.BookNumber</td>
|
||||
<td><a asp-controller="Books" asp-action="Details" asp-route-id="@book.BookID">@book.BookTitle</a></td>
|
||||
<td>@book.Description</td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-controller="Books" asp-action="Edit" asp-route-id="@book.BookID">Edit</a>
|
||||
<form asp-controller="Books" asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this book? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
||||
<input type="hidden" name="id" value="@book.BookID" />
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="row g-4">
|
||||
@foreach (var book in Model.Books)
|
||||
{
|
||||
<div class="col-12 col-md-6 col-xl-4 col-xxl-3">
|
||||
<article class="card h-100 overflow-hidden">
|
||||
<div class="row g-0 h-100">
|
||||
<div class="col-4">
|
||||
<img src="@book.CoverThumbnailPath"
|
||||
alt="Cover for @book.BookTitle"
|
||||
class="img-fluid h-100 w-100"
|
||||
style="aspect-ratio:2 / 3; object-fit:cover; min-height:220px;" />
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<div class="card-body h-100 d-flex flex-column">
|
||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
|
||||
<div>
|
||||
<p class="eyebrow mb-1">Book @book.BookNumber</p>
|
||||
<h3 class="h5 mb-1">
|
||||
<a asp-controller="Books" asp-action="Details" asp-route-id="@book.BookID">@book.BookTitle</a>
|
||||
</h3>
|
||||
@if (!string.IsNullOrWhiteSpace(book.Subtitle))
|
||||
{
|
||||
<p class="muted mb-0">@book.Subtitle</p>
|
||||
}
|
||||
</div>
|
||||
<span class="status-pill">@book.Status</span>
|
||||
</div>
|
||||
|
||||
<p class="mb-2">@book.WordCountLabel</p>
|
||||
@if (book.ProgressPercentage.HasValue)
|
||||
{
|
||||
var progressValue = Math.Round(book.ProgressPercentage.Value);
|
||||
<div class="progress mb-3" role="progressbar" aria-label="Book progress" aria-valuenow="@progressValue" aria-valuemin="0" aria-valuemax="100" style="height:8px;">
|
||||
<div class="progress-bar" style="width:@progressValue%"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-3 flex-wrap small text-muted mb-3">
|
||||
<span>@book.ChapterCount.ToString("N0") chapters</span>
|
||||
<span>@book.SceneCount.ToString("N0") scenes</span>
|
||||
<span>Modified @book.UpdatedDate.ToString("dd MMM yyyy")</span>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-auto">
|
||||
<a class="btn btn-primary btn-sm" asp-controller="Books" asp-action="Details" asp-route-id="@book.BookID">Open Book</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-controller="Books" asp-action="Edit" asp-route-id="@book.BookID">Edit Book</a>
|
||||
<form asp-controller="Books" asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this book? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
||||
<input type="hidden" name="id" value="@book.BookID" />
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user