Phase 9G: Writing Plan Management

This commit is contained in:
Nick Beckley 2026-06-12 22:46:18 +01:00
parent 04dc07e5f5
commit 8f006874ac
9 changed files with 645 additions and 10 deletions

View File

@ -29,6 +29,65 @@ public sealed class WriterController(
return View(model);
}
public async Task<IActionResult> WritingPlans()
{
var model = await writingSchedule.GetWritingPlansForManagementAsync();
return View(model);
}
public async Task<IActionResult> EditWritingPlan(int id)
{
try
{
var model = await writingSchedule.GetWritingPlanSettingsAsync(id);
return View(model);
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditWritingPlan(WritingPlanSettingsEditViewModel model)
{
if (!Request.Form.ContainsKey(nameof(model.SelectedWritingDays)))
{
model.SelectedWritingDays = [];
}
if (!model.SelectedWritingDays.Any())
{
ModelState.AddModelError(string.Empty, "Select at least one writing day.");
ModelState.AddModelError(nameof(model.SelectedWritingDays), "Select at least one writing day.");
}
if (model.DeadlineDate.Date < model.StartDate.Date)
{
ModelState.AddModelError(nameof(model.DeadlineDate), "Deadline date must be on or after the start date.");
}
if (!ModelState.IsValid)
{
await PopulatePlanSettingsDisplayAsync(model);
return View(model);
}
try
{
await writingSchedule.UpdateWritingPlanSettingsAsync(model);
TempData["WritingPlansMessage"] = "Writing plan updated.";
return RedirectToAction(nameof(WritingPlans));
}
catch (InvalidOperationException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
await PopulatePlanSettingsDisplayAsync(model);
return View(model);
}
}
public async Task<IActionResult> PreviewRebalance(int writingPlanId)
{
var model = await writingSchedule.GetSchedulePreviewAsync(writingPlanId);
@ -120,8 +179,20 @@ public sealed class WriterController(
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteGeneratedSchedule(int writingPlanId)
{
await writingSchedule.DeleteScheduleAsync(writingPlanId);
TempData["SchedulePreviewMessage"] = "Generated schedule deleted.";
try
{
await writingSchedule.DeleteScheduleAsync(writingPlanId);
TempData["SchedulePreviewMessage"] = "Generated schedule deleted.";
}
catch (InvalidOperationException ex)
{
TempData["SchedulePreviewError"] = ex.Message;
}
catch
{
TempData["SchedulePreviewError"] = "Generated schedule could not be deleted.";
}
return RedirectToAction(nameof(SchedulePreview), new { writingPlanId });
}
@ -142,6 +213,74 @@ public sealed class WriterController(
return RedirectToAction(nameof(SchedulePreview), new { writingPlanId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ActivateWritingPlan(int writingPlanId)
{
try
{
await writingSchedule.ActivateWritingPlanAsync(writingPlanId);
TempData["WritingPlansMessage"] = "Writing plan activated.";
}
catch (InvalidOperationException ex)
{
TempData["WritingPlansError"] = ex.Message;
}
return RedirectToAction(nameof(WritingPlans));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeactivateWritingPlan(int writingPlanId)
{
try
{
await writingSchedule.DeactivateWritingPlanAsync(writingPlanId);
TempData["WritingPlansMessage"] = "Writing plan deactivated.";
}
catch (InvalidOperationException ex)
{
TempData["WritingPlansError"] = ex.Message;
}
return RedirectToAction(nameof(WritingPlans));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteWritingPlan(int writingPlanId)
{
try
{
await writingSchedule.DeleteWritingPlanAsync(writingPlanId);
TempData["WritingPlansMessage"] = "Writing plan deleted.";
}
catch (InvalidOperationException ex)
{
TempData["WritingPlansError"] = ex.Message;
}
return RedirectToAction(nameof(WritingPlans));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegenerateWritingPlanSchedule(int writingPlanId)
{
try
{
var items = await writingSchedule.RegenerateScheduleAsync(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<IActionResult> MarkScheduleItemComplete(int writingScheduleItemId, int? projectId)
@ -171,4 +310,21 @@ public sealed class WriterController(
await writerWorkspace.SaveChapterGoalAsync(model);
return RedirectToAction(nameof(Chapter), new { id = model.ChapterID });
}
private async Task PopulatePlanSettingsDisplayAsync(WritingPlanSettingsEditViewModel model)
{
try
{
var existing = await writingSchedule.GetWritingPlanSettingsAsync(model.WritingPlanID);
model.ProjectName = existing.ProjectName;
model.BookName = existing.BookName;
model.GoalType = existing.GoalType;
model.StartDate = existing.StartDate;
model.RebalanceModeOptions = existing.RebalanceModeOptions;
}
catch (InvalidOperationException)
{
// The service will surface the ownership/not-found validation message on submit.
}
}
}

View File

@ -286,6 +286,7 @@ public interface IWritingScheduleRepository
Task UpdateScheduleItemAsync(WritingScheduleItem item);
Task DeleteScheduleItemAsync(int writingScheduleItemId);
Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc);
Task<IReadOnlyList<WritingPlanManagementItem>> GetWritingPlansForManagementAsync(int userId);
Task<IReadOnlyList<WritingScheduleScene>> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes);
Task ReplaceScheduleItemsAsync(int writingPlanId, IReadOnlyList<WritingScheduleItem> items);
Task<IReadOnlyList<WritingPlanSummary>> GetWritingPlanSummariesByUserAsync(int userId);
@ -1014,6 +1015,48 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<WritingPlanManagementItem>> GetWritingPlansForManagementAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<WritingPlanManagementItem>(
"""
SELECT wp.WritingPlanID,
wp.UserID,
wp.ProjectID,
p.ProjectName,
wp.BookID,
b.BookTitle,
wp.PlanName,
wp.GoalType,
wp.StartDate,
wp.DeadlineDate,
wp.SessionLengthMinutes,
wp.IsActive,
SUM(CASE WHEN wsi.ScheduleStatus = N'Planned' THEN 1 ELSE 0 END) AS PlannedTaskCount,
MAX(CASE WHEN wsi.ScheduleStatus = N'Planned' THEN wsi.ScheduledDate ELSE NULL END) AS ProjectedFinishDate
FROM dbo.WritingPlans wp
INNER JOIN dbo.Projects p ON p.ProjectID = wp.ProjectID
LEFT JOIN dbo.Books b ON b.BookID = wp.BookID
LEFT JOIN dbo.WritingScheduleItems wsi ON wsi.WritingPlanID = wp.WritingPlanID
WHERE wp.UserID = @UserID
GROUP BY wp.WritingPlanID,
wp.UserID,
wp.ProjectID,
p.ProjectName,
wp.BookID,
b.BookTitle,
wp.PlanName,
wp.GoalType,
wp.StartDate,
wp.DeadlineDate,
wp.SessionLengthMinutes,
wp.IsActive
ORDER BY wp.IsActive DESC, wp.DeadlineDate, wp.PlanName, wp.WritingPlanID;
""",
new { UserID = userId });
return rows.ToList();
}
public async Task<IReadOnlyList<WritingScheduleScene>> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -1632,6 +1632,24 @@ public sealed class WritingPlanSummary
public bool IsActive { get; set; }
}
public sealed class WritingPlanManagementItem
{
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 string GoalType { get; set; } = string.Empty;
public DateTime StartDate { get; set; }
public DateTime DeadlineDate { get; set; }
public int SessionLengthMinutes { get; set; }
public bool IsActive { get; set; }
public int PlannedTaskCount { get; set; }
public DateTime? ProjectedFinishDate { get; set; }
}
public sealed class WritingSchedulePreviewItem
{
public int WritingScheduleItemID { get; set; }

View File

@ -274,6 +274,12 @@ public interface IWritingScheduleService
Task<IReadOnlyList<WritingScheduleItemViewModel>> GenerateSchedule(int writingPlanId);
Task<WritingSchedulePreviewViewModel> GetSchedulePreviewAsync(int? writingPlanId);
Task DeleteScheduleAsync(int writingPlanId);
Task<WritingPlansManagementViewModel> GetWritingPlansForManagementAsync();
Task<WritingPlanSettingsEditViewModel> GetWritingPlanSettingsAsync(int writingPlanId);
Task UpdateWritingPlanSettingsAsync(WritingPlanSettingsEditViewModel model);
Task ActivateWritingPlanAsync(int writingPlanId);
Task DeactivateWritingPlanAsync(int writingPlanId);
Task<IReadOnlyList<WritingScheduleItemViewModel>> RegenerateScheduleAsync(int writingPlanId);
Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId);
Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model);
Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model);
@ -3318,11 +3324,18 @@ public sealed class WritingScheduleService(
return writingSchedule.UpdateWritingPlanAsync(ToModel(model));
}
public Task DeleteWritingPlanAsync(int writingPlanId) =>
writingSchedule.DeleteWritingPlanAsync(writingPlanId);
public async Task DeleteWritingPlanAsync(int writingPlanId)
{
await GetPlanForCurrentUserAsync(writingPlanId);
await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId);
await writingSchedule.DeleteWritingPlanAsync(writingPlanId);
}
public Task SetWritingPlanActiveAsync(int writingPlanId, bool isActive) =>
writingSchedule.SetWritingPlanActiveAsync(writingPlanId, isActive);
public async Task SetWritingPlanActiveAsync(int writingPlanId, bool isActive)
{
await GetPlanForCurrentUserAsync(writingPlanId);
await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, isActive);
}
public async Task<IReadOnlyList<WritingScheduleItemViewModel>> GetScheduleItemsByPlanAsync(int writingPlanId)
{
@ -3358,6 +3371,11 @@ public sealed class WritingScheduleService(
{
var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId)
?? throw new InvalidOperationException("Writing plan not found.");
if (currentUser.UserId.HasValue && plan.UserID != currentUser.UserId.Value)
{
throw new InvalidOperationException("Writing plan not found.");
}
var planViewModel = ToViewModel(plan);
ValidatePlan(planViewModel);
@ -3430,6 +3448,90 @@ public sealed class WritingScheduleService(
}
}
public async Task<WritingPlansManagementViewModel> GetWritingPlansForManagementAsync()
{
if (!currentUser.UserId.HasValue)
{
return new WritingPlansManagementViewModel();
}
var plans = await writingSchedule.GetWritingPlansForManagementAsync(currentUser.UserId.Value);
return new WritingPlansManagementViewModel { Plans = plans };
}
public async Task<WritingPlanSettingsEditViewModel> GetWritingPlanSettingsAsync(int writingPlanId)
{
var plan = await GetPlanForCurrentUserAsync(writingPlanId);
var project = await projects.GetAsync(plan.ProjectID);
var book = plan.BookID.HasValue ? await books.GetAsync(plan.BookID.Value) : null;
var selectedDays = ParseAvailableDays(plan.AvailableDaysJson)
.Select(x => x.ToString())
.ToList();
var model = new WritingPlanSettingsEditViewModel
{
WritingPlanID = plan.WritingPlanID,
PlanName = plan.PlanName,
ProjectName = project?.ProjectName ?? string.Empty,
BookName = book?.BookTitle ?? "All Books",
GoalType = plan.GoalType,
StartDate = plan.StartDate.Date,
DeadlineDate = plan.DeadlineDate.Date,
SessionLengthMinutes = plan.SessionLengthMinutes,
SelectedWritingDays = selectedDays,
IncludeBlockedScenes = plan.IncludeBlockedScenes,
RebalanceMode = plan.RebalanceMode,
IsActive = plan.IsActive
};
PopulatePlanSettingsOptions(model);
return model;
}
public async Task UpdateWritingPlanSettingsAsync(WritingPlanSettingsEditViewModel model)
{
var plan = await GetPlanForCurrentUserAsync(model.WritingPlanID);
ValidatePlanSettings(model);
var writingDays = NormaliseWritingDays(model.SelectedWritingDays);
if (writingDays.Count == 0)
{
throw new InvalidOperationException("Select at least one writing day.");
}
plan.PlanName = model.PlanName.Trim();
plan.DeadlineDate = model.DeadlineDate.Date;
plan.AvailableDaysJson = JsonSerializer.Serialize(writingDays);
plan.SessionLengthMinutes = model.SessionLengthMinutes;
plan.IncludeBlockedScenes = model.IncludeBlockedScenes;
plan.RebalanceMode = model.RebalanceMode;
plan.IsActive = model.IsActive;
await writingSchedule.UpdateWritingPlanAsync(plan);
}
public async Task ActivateWritingPlanAsync(int writingPlanId)
{
await GetPlanForCurrentUserAsync(writingPlanId);
await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, true);
}
public async Task DeactivateWritingPlanAsync(int writingPlanId)
{
await GetPlanForCurrentUserAsync(writingPlanId);
await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, false);
}
public async Task<IReadOnlyList<WritingScheduleItemViewModel>> RegenerateScheduleAsync(int writingPlanId)
{
await GetPlanForCurrentUserAsync(writingPlanId);
await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId);
return await GenerateSchedule(writingPlanId);
}
private static void PopulatePlanSettingsOptions(WritingPlanSettingsEditViewModel model)
{
model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode);
}
public async Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId)
{
var projectRows = currentUser.UserId.HasValue
@ -3690,6 +3792,22 @@ public sealed class WritingScheduleService(
return plan;
}
private async Task<WritingPlan> GetPlanForCurrentUserAsync(int writingPlanId)
{
if (!currentUser.UserId.HasValue)
{
throw new InvalidOperationException("Sign in before managing a writing schedule.");
}
var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId);
if (plan is null || plan.UserID != currentUser.UserId.Value)
{
throw new InvalidOperationException("Writing plan not found.");
}
return plan;
}
private static void ValidateSetup(WritingScheduleSetupViewModel model)
{
if (string.IsNullOrWhiteSpace(model.PlanName))
@ -3713,6 +3831,24 @@ public sealed class WritingScheduleService(
}
}
private static void ValidatePlanSettings(WritingPlanSettingsEditViewModel model)
{
if (string.IsNullOrWhiteSpace(model.PlanName))
{
throw new InvalidOperationException("Plan name is required.");
}
if (model.DeadlineDate.Date < model.StartDate.Date)
{
throw new InvalidOperationException("Deadline date must be on or after the start date.");
}
if (model.SessionLengthMinutes <= 0)
{
throw new InvalidOperationException("Session length minutes must be greater than zero.");
}
}
private static IReadOnlyList<string> NormaliseWritingDays(IEnumerable<string>? selectedDays)
{
var selected = (selectedDays ?? [])

View File

@ -356,6 +356,48 @@ public sealed class WritingPlanViewModel
public DateTime ModifiedDateUtc { get; set; }
}
public sealed class WritingPlansManagementViewModel
{
public IReadOnlyList<WritingPlanManagementItem> Plans { get; set; } = [];
}
public sealed class WritingPlanSettingsEditViewModel
{
public int WritingPlanID { get; set; }
[Required, StringLength(200)]
public string PlanName { get; set; } = string.Empty;
public string ProjectName { get; set; } = string.Empty;
public string BookName { get; set; } = "All Books";
public string GoalType { get; set; } = string.Empty;
[DataType(DataType.Date)]
public DateTime StartDate { get; set; }
[DataType(DataType.Date)]
public DateTime DeadlineDate { get; set; }
[Range(1, int.MaxValue, ErrorMessage = "Session length minutes must be greater than zero.")]
public int SessionLengthMinutes { get; set; }
public List<string> SelectedWritingDays { get; set; } = [];
public bool IncludeBlockedScenes { get; set; }
public string RebalanceMode { get; set; } = "Ask";
public bool IsActive { get; set; }
public IReadOnlyList<SelectListItem> RebalanceModeOptions { get; set; } = [];
public IReadOnlyList<string> WritingDayOptions { get; set; } =
[
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
];
}
public sealed class WritingScheduleItemViewModel
{
public int WritingScheduleItemID { get; set; }

View File

@ -0,0 +1,105 @@
@model WritingPlanSettingsEditViewModel
@{
ViewData["Title"] = "Edit Writing Plan";
var selectedDays = Model.SelectedWritingDays.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Writer" asp-action="Index">Writer Workspace</a>
<a asp-controller="Writer" asp-action="WritingPlans">Writing Plans</a>
<span>Edit Plan</span>
</nav>
<div class="page-heading compact">
<div>
<p class="eyebrow">Schedule admin</p>
<h1>Edit Writing Plan</h1>
<p class="lead-text">Update plan settings without changing the project, book, or goal type.</p>
</div>
</div>
<section class="story-bible-section">
<form asp-action="EditWritingPlan" method="post" class="story-bible-card chapter-goal-form">
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<input type="hidden" asp-for="WritingPlanID" />
<input type="hidden" asp-for="StartDate" />
<div class="row g-3">
<div class="col-md-6">
<label asp-for="PlanName" class="form-label">Plan Name</label>
<input asp-for="PlanName" class="form-control" />
<span asp-validation-for="PlanName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label class="form-label">Project</label>
<input class="form-control" value="@Model.ProjectName" readonly />
</div>
<div class="col-md-6">
<label class="form-label">Book</label>
<input class="form-control" value="@Model.BookName" readonly />
</div>
<div class="col-md-6">
<label class="form-label">Goal Type</label>
<input class="form-control" value="@Model.GoalType" readonly />
</div>
<div class="col-md-6">
<label class="form-label">Start Date</label>
<input class="form-control" value='@Model.StartDate.ToString("yyyy-MM-dd")' readonly />
</div>
<div class="col-md-6">
<label asp-for="DeadlineDate" class="form-label">Deadline Date</label>
<input asp-for="DeadlineDate" class="form-control" type="date" />
<span asp-validation-for="DeadlineDate" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="SessionLengthMinutes" class="form-label">Session Length Minutes</label>
<input asp-for="SessionLengthMinutes" class="form-control" type="number" min="1" />
<span asp-validation-for="SessionLengthMinutes" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="RebalanceMode" class="form-label">Rebalance Mode</label>
<select asp-for="RebalanceMode" asp-items="Model.RebalanceModeOptions" class="form-select"></select>
<span asp-validation-for="RebalanceMode" class="text-danger"></span>
</div>
<div class="col-12">
<label class="form-label">Available Writing Days</label>
<div class="writer-checkbox-grid">
@foreach (var day in Model.WritingDayOptions)
{
<label>
<input type="checkbox" name="SelectedWritingDays" value="@day" checked="@selectedDays.Contains(day)" />
@day
</label>
}
</div>
<span asp-validation-for="SelectedWritingDays" class="text-danger"></span>
</div>
<div class="col-12 writer-checkbox-grid">
<label>
<input asp-for="IncludeBlockedScenes" />
Include Blocked Scenes
</label>
<label>
<input asp-for="IsActive" />
Active
</label>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Save Plan</button>
<a class="btn btn-outline-secondary" asp-action="WritingPlans">Cancel</a>
</div>
</form>
</section>
<partial name="_ValidationScriptsPartial" />

View File

@ -12,6 +12,7 @@
<p class="lead-text">A practical control room for drafting, revision, polish, and story readiness.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-primary" asp-action="WritingPlans">Writing Plans</a>
<a class="btn btn-outline-primary" asp-action="ScheduleSetup" asp-route-projectId="@Model.ProjectID">Schedule Setup</a>
<a class="btn btn-outline-secondary" asp-action="SchedulePreview">Schedule Preview</a>
<form asp-action="Index" method="get" class="story-bible-search">

View File

@ -107,10 +107,7 @@ else
<input type="hidden" name="writingPlanId" value="@Model.SelectedPlan.WritingPlanID" />
<button class="btn btn-primary" type="submit">Generate Schedule</button>
</form>
<form asp-action="DeleteGeneratedSchedule" method="post" onsubmit="return confirm('Delete generated schedule items for this writing plan?');">
<input type="hidden" name="writingPlanId" value="@Model.SelectedPlan.WritingPlanID" />
<button class="btn btn-outline-danger" type="submit">Delete Generated Schedule</button>
</form>
<button class="btn btn-outline-danger" type="button" data-bs-toggle="modal" data-bs-target="#deleteGeneratedScheduleModal">Delete Generated Schedule</button>
@if (Model.SelectedPlanRequiresRebalance)
{
<a class="btn btn-outline-primary" asp-action="PreviewRebalance" asp-route-writingPlanId="@Model.SelectedPlan.WritingPlanID">Preview Rebalance</a>
@ -118,6 +115,28 @@ else
</div>
</section>
<div class="modal fade" id="deleteGeneratedScheduleModal" tabindex="-1" aria-labelledby="deleteGeneratedScheduleModalTitle" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="deleteGeneratedScheduleModalTitle">Delete generated schedule?</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Delete generated schedule items for <strong>@Model.SelectedPlan.PlanName</strong>?</p>
<p class="muted mb-0">This removes the generated schedule items for this plan. The writing plan itself will remain.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form asp-action="DeleteGeneratedSchedule" method="post" class="m-0">
<input type="hidden" name="writingPlanId" value="@Model.SelectedPlan.WritingPlanID" />
<button class="btn btn-danger" type="submit">Delete</button>
</form>
</div>
</div>
</div>
</div>
@if (Model.RebalancePreview is not null)
{
<section class="story-bible-section">

View File

@ -0,0 +1,115 @@
@model WritingPlansManagementViewModel
@{
ViewData["Title"] = "Writing Plans";
var message = TempData["WritingPlansMessage"] as string;
var error = TempData["WritingPlansError"] as string;
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Writer" asp-action="Index">Writer Workspace</a>
<span>Writing Plans</span>
</nav>
<div class="page-heading compact">
<div>
<p class="eyebrow">Schedule admin</p>
<h1>Writing Plans</h1>
<p class="lead-text">Manage writing plans and their generated schedules.</p>
</div>
<div class="button-row">
<a class="btn btn-primary" asp-action="ScheduleSetup">Create Writing Schedule</a>
<a class="btn btn-outline-secondary" asp-action="SchedulePreview">Schedule Preview</a>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(message))
{
<div class="alert alert-info">@message</div>
}
@if (!string.IsNullOrWhiteSpace(error))
{
<div class="alert alert-warning">@error</div>
}
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Plans</p>
<h2>Writing Plans</h2>
</div>
<span class="soft-count">@Model.Plans.Count plans</span>
</div>
@if (!Model.Plans.Any())
{
<p class="muted">No writing plans found.</p>
}
else
{
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Plan Name</th>
<th>Project</th>
<th>Book</th>
<th>Goal Type</th>
<th>Start Date</th>
<th>Deadline Date</th>
<th>Session Length</th>
<th>Active</th>
<th>Planned Tasks</th>
<th>Projected Finish Date</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var plan in Model.Plans)
{
<tr>
<td>@plan.PlanName</td>
<td>@plan.ProjectName</td>
<td>@(plan.BookTitle ?? "All Books")</td>
<td>@plan.GoalType</td>
<td>@plan.StartDate.ToString("dd MMM yyyy")</td>
<td>@plan.DeadlineDate.ToString("dd MMM yyyy")</td>
<td>@plan.SessionLengthMinutes minutes</td>
<td>@(plan.IsActive ? "Yes" : "No")</td>
<td>@plan.PlannedTaskCount</td>
<td>@(plan.ProjectedFinishDate?.ToString("dd MMM yyyy") ?? "-")</td>
<td>
<div class="button-row">
<a class="btn btn-sm btn-outline-secondary" asp-action="SchedulePreview" asp-route-writingPlanId="@plan.WritingPlanID">View Schedule</a>
<a class="btn btn-sm btn-outline-primary" asp-action="EditWritingPlan" asp-route-id="@plan.WritingPlanID">Edit Plan</a>
@if (plan.IsActive)
{
<form asp-action="DeactivateWritingPlan" method="post">
<input type="hidden" name="writingPlanId" value="@plan.WritingPlanID" />
<button class="btn btn-sm btn-outline-secondary" type="submit">Deactivate Plan</button>
</form>
}
else
{
<form asp-action="ActivateWritingPlan" method="post">
<input type="hidden" name="writingPlanId" value="@plan.WritingPlanID" />
<button class="btn btn-sm btn-outline-secondary" type="submit">Activate Plan</button>
</form>
}
<form asp-action="RegenerateWritingPlanSchedule" method="post">
<input type="hidden" name="writingPlanId" value="@plan.WritingPlanID" />
<button class="btn btn-sm btn-outline-primary" type="submit">Regenerate Schedule</button>
</form>
<form asp-action="DeleteWritingPlan" method="post" onsubmit="return confirm('Delete this writing plan and its generated schedule items?');">
<input type="hidden" name="writingPlanId" value="@plan.WritingPlanID" />
<button class="btn btn-sm btn-outline-danger" type="submit">Delete Plan</button>
</form>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>