diff --git a/PlotLine/Controllers/WriterController.cs b/PlotLine/Controllers/WriterController.cs index 4e06eaf..1369d1b 100644 --- a/PlotLine/Controllers/WriterController.cs +++ b/PlotLine/Controllers/WriterController.cs @@ -6,7 +6,9 @@ using PlotLine.ViewModels; namespace PlotLine.Controllers; [Authorize] -public sealed class WriterController(IWriterWorkspaceService writerWorkspace) : Controller +public sealed class WriterController( + IWriterWorkspaceService writerWorkspace, + IWritingScheduleService writingSchedule) : Controller { public async Task Index(int? projectId) { @@ -20,6 +22,38 @@ public sealed class WriterController(IWriterWorkspaceService writerWorkspace) : return model is null ? NotFound() : View(model); } + public async Task SchedulePreview(int? writingPlanId) + { + var model = await writingSchedule.GetSchedulePreviewAsync(writingPlanId); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task GenerateSchedule(int writingPlanId) + { + try + { + var items = await writingSchedule.GenerateSchedule(writingPlanId); + TempData["SchedulePreviewMessage"] = $"{items.Count} item{(items.Count == 1 ? "" : "s")} generated."; + } + catch (InvalidOperationException ex) + { + TempData["SchedulePreviewError"] = ex.Message; + } + + return RedirectToAction(nameof(SchedulePreview), new { writingPlanId }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DeleteGeneratedSchedule(int writingPlanId) + { + await writingSchedule.DeleteScheduleAsync(writingPlanId); + TempData["SchedulePreviewMessage"] = "Generated schedule deleted."; + return RedirectToAction(nameof(SchedulePreview), new { writingPlanId }); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task SaveChapterGoal(ChapterGoalEditViewModel model) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 2132af5..75b4760 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -288,6 +288,9 @@ public interface IWritingScheduleRepository Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc); Task> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes); Task ReplaceScheduleItemsAsync(int writingPlanId, IReadOnlyList items); + Task> GetWritingPlanSummariesByUserAsync(int userId); + Task> GetSchedulePreviewItemsAsync(int writingPlanId); + Task DeleteScheduleItemsByPlanAsync(int writingPlanId); } public interface IExportRepository @@ -1088,6 +1091,70 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa throw; } } + + public async Task> GetWritingPlanSummariesByUserAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + SELECT wp.WritingPlanID, + wp.UserID, + wp.ProjectID, + p.ProjectName, + wp.BookID, + b.BookTitle, + wp.PlanName, + wp.DeadlineDate, + wp.IsActive + FROM dbo.WritingPlans wp + INNER JOIN dbo.Projects p ON p.ProjectID = wp.ProjectID + LEFT JOIN dbo.Books b ON b.BookID = wp.BookID + WHERE wp.UserID = @UserID + ORDER BY wp.IsActive DESC, wp.DeadlineDate, wp.PlanName, wp.WritingPlanID; + """, + new { UserID = userId }); + return rows.ToList(); + } + + public async Task> GetSchedulePreviewItemsAsync(int writingPlanId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + SELECT wsi.WritingScheduleItemID, + wsi.WritingPlanID, + wsi.SceneID, + wsi.ScheduledDate, + wsi.TaskType, + b.BookTitle, + c.ChapterNumber, + c.ChapterTitle, + s.SceneNumber, + s.SceneTitle, + sw.Priority, + wsi.EstimatedMinutes, + wsi.TargetWords, + wsi.ScheduleStatus, + COALESCE(sw.IsBlocked, 0) AS IsBlocked + FROM dbo.WritingScheduleItems wsi + INNER JOIN dbo.Scenes s ON s.SceneID = wsi.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID + WHERE wsi.WritingPlanID = @WritingPlanID + ORDER BY wsi.ScheduledDate, wsi.WritingScheduleItemID; + """, + new { WritingPlanID = writingPlanId }); + return rows.ToList(); + } + + public async Task DeleteScheduleItemsByPlanAsync(int writingPlanId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "DELETE FROM dbo.WritingScheduleItems WHERE WritingPlanID = @WritingPlanID;", + new { WritingPlanID = writingPlanId }); + } } public sealed class ExportRepository(ISqlConnectionFactory connectionFactory) : IExportRepository diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index cc74d9e..61c516a 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1619,6 +1619,38 @@ public sealed class WritingScheduleScene public int SceneSortOrder { get; set; } } +public sealed class WritingPlanSummary +{ + public int WritingPlanID { get; set; } + public int UserID { get; set; } + public int ProjectID { get; set; } + public string ProjectName { get; set; } = string.Empty; + public int? BookID { get; set; } + public string? BookTitle { get; set; } + public string PlanName { get; set; } = string.Empty; + public DateTime DeadlineDate { get; set; } + public bool IsActive { get; set; } +} + +public sealed class WritingSchedulePreviewItem +{ + public int WritingScheduleItemID { get; set; } + public int WritingPlanID { get; set; } + public int SceneID { get; set; } + public DateTime ScheduledDate { get; set; } + public string TaskType { get; set; } = string.Empty; + public string BookTitle { get; set; } = string.Empty; + public decimal ChapterNumber { get; set; } + public string ChapterTitle { get; set; } = string.Empty; + public decimal SceneNumber { get; set; } + public string SceneTitle { get; set; } = string.Empty; + public int? Priority { get; set; } + public int? EstimatedMinutes { get; set; } + public int? TargetWords { get; set; } + public string ScheduleStatus { get; set; } = string.Empty; + public bool IsBlocked { get; set; } +} + public sealed class ChapterWorkflowScene { public int SceneID { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index e2e4c4d..f23fa2b 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -272,6 +272,8 @@ public interface IWritingScheduleService Task DeleteScheduleItemAsync(int writingScheduleItemId); Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc); Task> GenerateSchedule(int writingPlanId); + Task GetSchedulePreviewAsync(int? writingPlanId); + Task DeleteScheduleAsync(int writingPlanId); } public interface IStorageService @@ -3277,7 +3279,9 @@ public sealed class WriterWorkspaceService( public Task DeleteAttachmentAsync(int sceneAttachmentId) => writerWorkspace.DeleteAttachmentAsync(sceneAttachmentId); } -public sealed class WritingScheduleService(IWritingScheduleRepository writingSchedule) : IWritingScheduleService +public sealed class WritingScheduleService( + IWritingScheduleRepository writingSchedule, + ICurrentUserService currentUser) : IWritingScheduleService { public async Task> GetWritingPlansByUserAsync(int userId) { @@ -3375,6 +3379,44 @@ public sealed class WritingScheduleService(IWritingScheduleRepository writingSch return savedItems.Select(ToViewModel).ToList(); } + public async Task GetSchedulePreviewAsync(int? writingPlanId) + { + if (!currentUser.UserId.HasValue) + { + return new WritingSchedulePreviewViewModel(); + } + + var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value); + var selectedPlan = writingPlanId.HasValue + ? plans.FirstOrDefault(x => x.WritingPlanID == writingPlanId.Value) + : plans.FirstOrDefault(); + var items = selectedPlan is null + ? [] + : await writingSchedule.GetSchedulePreviewItemsAsync(selectedPlan.WritingPlanID); + + return new WritingSchedulePreviewViewModel + { + SelectedWritingPlanID = selectedPlan?.WritingPlanID, + Plans = plans, + SelectedPlan = selectedPlan, + Items = items + }; + } + + public async Task DeleteScheduleAsync(int writingPlanId) + { + if (!currentUser.UserId.HasValue) + { + return; + } + + var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value); + if (plans.Any(x => x.WritingPlanID == writingPlanId)) + { + await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId); + } + } + private static void ValidatePlan(WritingPlanViewModel model) { if (model.DeadlineDate.Date < model.StartDate.Date) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index a6aa689..bcb034d 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -356,6 +356,20 @@ public sealed class WritingScheduleItemViewModel public DateTime ModifiedDateUtc { get; set; } } +public sealed class WritingSchedulePreviewViewModel +{ + public int? SelectedWritingPlanID { get; set; } + public IReadOnlyList Plans { get; set; } = []; + public WritingPlanSummary? SelectedPlan { get; set; } + public IReadOnlyList Items { get; set; } = []; + public int TotalTasks => Items.Count; + public int DraftTasks => Items.Count(x => x.TaskType == "Draft"); + public int RevisionTasks => Items.Count(x => x.TaskType == "Revise"); + public int PolishTasks => Items.Count(x => x.TaskType == "Polish"); + public int TotalEstimatedMinutes => Items.Sum(x => x.EstimatedMinutes ?? 0); + public DateTime? ProjectedFinishDate => Items.Count == 0 ? null : Items.Max(x => x.ScheduledDate); +} + public sealed class CharacterArcViewModel { public Project Project { get; set; } = new(); diff --git a/PlotLine/Views/Writer/Index.cshtml b/PlotLine/Views/Writer/Index.cshtml index 8caf2cb..342637f 100644 --- a/PlotLine/Views/Writer/Index.cshtml +++ b/PlotLine/Views/Writer/Index.cshtml @@ -9,12 +9,15 @@

Writer Workspace

A practical control room for drafting, revision, polish, and story readiness.

-
- - -
+
+ Schedule Preview +
+ + +
+
diff --git a/PlotLine/Views/Writer/SchedulePreview.cshtml b/PlotLine/Views/Writer/SchedulePreview.cshtml new file mode 100644 index 0000000..6d32137 --- /dev/null +++ b/PlotLine/Views/Writer/SchedulePreview.cshtml @@ -0,0 +1,182 @@ +@model WritingSchedulePreviewViewModel +@{ + ViewData["Title"] = "Schedule Preview"; + var message = TempData["SchedulePreviewMessage"] as string; + var error = TempData["SchedulePreviewError"] as string; +} + + + +
+
+

Validation

+

Schedule Preview

+

Generated writing schedule items.

+
+
+ +@if (!string.IsNullOrWhiteSpace(message)) +{ +
@message
+} + +@if (!string.IsNullOrWhiteSpace(error)) +{ +
@error
+} + +@if (!Model.Plans.Any()) +{ +
+

No writing plans found.

+
+} +else +{ +
+
+
+

Plan

+

Writing Plan Selection

+
+
+ +
+ + +
+ +
+ + + + + + + + + + + + + @foreach (var plan in Model.Plans) + { + + + + + + + + + } + +
Plan NameProjectBookDeadline DateActive
@plan.PlanName@plan.ProjectName@(plan.BookTitle ?? "All Books")@plan.DeadlineDate.ToString("dd MMM yyyy")@(plan.IsActive ? "Yes" : "No") + @if (plan.WritingPlanID == Model.SelectedWritingPlanID) + { + Selected + } + else + { + Select + } +
+
+
+ + @if (Model.SelectedPlan is not null) + { +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+

Summary

+

Schedule Summary

+
+
+
+

Total Tasks

@Model.TotalTasks
+

Draft Tasks

@Model.DraftTasks
+

Revision Tasks

@Model.RevisionTasks
+

Polish Tasks

@Model.PolishTasks
+

Total Estimated Minutes

@Model.TotalEstimatedMinutes
+

Projected Finish Date

@(Model.ProjectedFinishDate?.ToString("dd MMM yyyy") ?? "-")
+
+
+ +
+
+
+

Items

+

Schedule Preview Table

+
+ @Model.Items.Count items +
+ + @if (!Model.Items.Any()) + { +

No schedule generated.

+ } + else + { +
+ + + + + + + + + + + + + + + + + @foreach (var item in Model.Items) + { + + + + + + + + + + + + + } + +
Scheduled DateTask TypeBookChapterScenePriorityEstimated MinutesTarget WordsSchedule StatusBlocked
@item.ScheduledDate.ToString("dd MMM yyyy")@item.TaskType@item.BookTitleChapter @item.ChapterNumber: @item.ChapterTitleScene @item.SceneNumber: @item.SceneTitle@(item.Priority?.ToString() ?? "-")@(item.EstimatedMinutes?.ToString() ?? "-")@(item.TargetWords?.ToString() ?? "-")@item.ScheduleStatus@(item.IsBlocked ? "Yes" : "No")
+
+ } +
+ } +}