Практичний проєкт: TaskManager на Razor Pages
Практичний проєкт: TaskManager на Razor Pages
Цей проєкт — ваш повний CRUD-застосунок на Razor Pages. Ми будуємо менеджер задач з категоріями, пріоритетами і статусами. Кожен крок наростає на попередньому, і наприкінці ви матимете повноцінний working застосунок.
Структура проєкту
TaskManager/
├── Pages/
│ ├── Shared/
│ │ ├── _Layout.cshtml
│ │ ├── _TaskCard.cshtml ← Partial View
│ │ ├── _Pagination.cshtml ← Partial View
│ │ └── _ValidationScriptsPartial.cshtml
│ ├── _ViewStart.cshtml
│ ├── _ViewImports.cshtml
│ ├── Index.cshtml → /
│ ├── Tasks/
│ │ ├── Index.cshtml → /tasks
│ │ ├── Index.cshtml.cs
│ │ ├── Create.cshtml → /tasks/create
│ │ ├── Create.cshtml.cs
│ │ ├── Edit.cshtml → /tasks/edit/{id}
│ │ ├── Edit.cshtml.cs
│ │ ├── Details.cshtml → /tasks/details/{id}
│ │ ├── Details.cshtml.cs
│ │ └── Delete.cshtml → /tasks/delete/{id}
│ │ Delete.cshtml.cs
│ └── Categories/
│ ├── Index.cshtml → /categories
│ ├── Index.cshtml.cs
│ ├── Create.cshtml → /categories/create
│ └── Create.cshtml.cs
├── Models/
│ ├── TaskItem.cs
│ ├── Category.cs
│ └── Enums.cs
├── Data/
│ └── AppDbContext.cs
├── Services/
│ ├── TaskService.cs
│ └── CategoryService.cs
├── wwwroot/
│ └── css/
│ └── app.css
└── Program.cs
Крок 1: Моделі та налаштування
dotnet new webapp -n TaskManager
cd TaskManager
dotnet add package Microsoft.EntityFrameworkCore.InMemory
dotnet add package Microsoft.EntityFrameworkCore.Design
namespace TaskManager.Models;
public enum TaskPriority
{
Low = 0,
Medium = 1,
High = 2,
Critical = 3
}
public enum TaskStatus
{
Todo = 0,
InProgress = 1,
Done = 2,
Cancelled = 3
}
namespace TaskManager.Models;
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Color { get; set; } = "#6c757d"; // Bootstrap color hex
public ICollection<TaskItem> Tasks { get; set; } = [];
}
namespace TaskManager.Models;
public class TaskItem
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string? Description { get; set; }
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
public TaskStatus Status { get; set; } = TaskStatus.Todo;
public DateTime? DueDate { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
public bool IsOverdue => DueDate.HasValue
&& DueDate.Value < DateTime.UtcNow
&& Status != TaskStatus.Done;
public int? CategoryId { get; set; }
public Category? Category { get; set; }
}
using Microsoft.EntityFrameworkCore;
using TaskManager.Models;
namespace TaskManager.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<TaskItem> Tasks => Set<TaskItem>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Category>().HasData(
new Category { Id = 1, Name = "Робота", Color = "#0d6efd" },
new Category { Id = 2, Name = "Особисте", Color = "#198754" },
new Category { Id = 3, Name = "Навчання", Color = "#fd7e14" }
);
b.Entity<TaskItem>().HasData(
new TaskItem
{
Id = 1, Title = "Вивчити Razor Pages",
Priority = TaskPriority.High,
Status = TaskStatus.InProgress,
CategoryId = 3,
DueDate = DateTime.UtcNow.AddDays(7),
CreatedAt = DateTime.UtcNow
}
);
}
}
using Microsoft.EntityFrameworkCore;
using TaskManager.Data;
using TaskManager.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseInMemoryDatabase("TaskManagerDb"));
builder.Services.AddMemoryCache();
builder.Services.AddScoped<TaskService>();
builder.Services.AddScoped<CategoryService>();
builder.Services.AddRazorPages();
var app = builder.Build();
// Seed DB при старті
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
}
if (!app.Environment.IsDevelopment())
app.UseExceptionHandler("/Error");
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
Крок 2: Сервіси
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using TaskManager.Data;
using TaskManager.Models;
namespace TaskManager.Services;
public class TaskService
{
private readonly AppDbContext _db;
private readonly IMemoryCache _cache;
private readonly ILogger<TaskService> _logger;
private const string StatsKey = "task:stats";
public TaskService(AppDbContext db, IMemoryCache cache, ILogger<TaskService> logger)
{
_db = db;
_cache = cache;
_logger = logger;
}
public async Task<(List<TaskItem> Items, int Total)> GetPagedAsync(
string? search, TaskStatus? status, int? categoryId,
int page, int pageSize, CancellationToken ct = default)
{
var query = _db.Tasks
.Include(t => t.Category)
.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(t =>
t.Title.Contains(search) ||
(t.Description != null && t.Description.Contains(search)));
if (status.HasValue)
query = query.Where(t => t.Status == status.Value);
if (categoryId.HasValue)
query = query.Where(t => t.CategoryId == categoryId.Value);
var total = await query.CountAsync(ct);
var items = await query
.OrderByDescending(t => (int)t.Priority)
.ThenBy(t => t.DueDate)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return (items, total);
}
public async Task<TaskItem?> GetByIdAsync(int id, CancellationToken ct = default)
=> await _db.Tasks
.Include(t => t.Category)
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == id, ct);
public async Task<TaskItem> CreateAsync(TaskItem task, CancellationToken ct = default)
{
_db.Tasks.Add(task);
await _db.SaveChangesAsync(ct);
_cache.Remove(StatsKey);
_logger.LogInformation("Task [{Id}] '{Title}' created", task.Id, task.Title);
return task;
}
public async Task<bool> UpdateAsync(int id, TaskItem updated, CancellationToken ct = default)
{
var task = await _db.Tasks.FindAsync([id], ct);
if (task is null) return false;
task.Title = updated.Title;
task.Description = updated.Description;
task.Priority = updated.Priority;
task.Status = updated.Status;
task.DueDate = updated.DueDate;
task.CategoryId = updated.CategoryId;
if (updated.Status == TaskStatus.Done && task.CompletedAt is null)
task.CompletedAt = DateTime.UtcNow;
else if (updated.Status != TaskStatus.Done)
task.CompletedAt = null;
await _db.SaveChangesAsync(ct);
_cache.Remove(StatsKey);
return true;
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
var task = await _db.Tasks.FindAsync([id], ct);
if (task is null) return false;
_db.Tasks.Remove(task);
await _db.SaveChangesAsync(ct);
_cache.Remove(StatsKey);
return true;
}
public record TaskStats(int Total, int InProgress, int Done, int Overdue);
public async Task<TaskStats> GetStatsAsync(CancellationToken ct = default)
{
return await _cache.GetOrCreateAsync(StatsKey, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
var all = await _db.Tasks.AsNoTracking().ToListAsync(ct);
return new TaskStats(
all.Count,
all.Count(t => t.Status == TaskStatus.InProgress),
all.Count(t => t.Status == TaskStatus.Done),
all.Count(t => t.IsOverdue));
}) ?? new TaskStats(0, 0, 0, 0);
}
}
Крок 3: _Layout.cshtml
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@ViewData["Title"] — TaskManager</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="~/css/app.css" asp-append-version="true">
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm">
<div class="container">
<a class="navbar-brand fw-bold" asp-page="/Index">
<i class="bi bi-check2-square me-2"></i>TaskManager
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" asp-page="/Tasks/Index">
<i class="bi bi-list-task me-1"></i>Задачі
</a>
</li>
<li class="nav-item">
<a class="nav-link" asp-page="/Categories/Index">
<i class="bi bi-tags me-1"></i>Категорії
</a>
</li>
<li class="nav-item ms-2">
<a class="btn btn-primary btn-sm" asp-page="/Tasks/Create">
<i class="bi bi-plus-lg me-1"></i>Нова задача
</a>
</li>
</ul>
</div>
</div>
</nav>
<main class="container py-4">
@* Flash повідомлення — доступні після RedirectToPage *@
@if (TempData["SuccessMessage"] is string successMsg)
{
<div class="alert alert-success alert-dismissible fade show shadow-sm mb-4" role="alert">
<i class="bi bi-check-circle me-2"></i>@successMsg
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["ErrorMessage"] is string errorMsg)
{
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
<i class="bi bi-exclamation-circle me-2"></i>@errorMsg
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@RenderBody()
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
Крок 4: Список задач — Pages/Tasks/Index
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using TaskManager.Models;
using TaskManager.Services;
namespace TaskManager.Pages.Tasks;
public class IndexModel : PageModel
{
private readonly TaskService _svc;
private readonly CategoryService _categorySvc;
public IndexModel(TaskService svc, CategoryService categorySvc)
{
_svc = svc;
_categorySvc = categorySvc;
}
[BindProperty(SupportsGet = true)]
public string? Search { get; set; }
[BindProperty(SupportsGet = true)]
public TaskStatus? Status { get; set; }
[BindProperty(SupportsGet = true)]
public int? CategoryId { get; set; }
[BindProperty(SupportsGet = true)]
public int Page { get; set; } = 1;
public const int PageSize = 10;
public List<TaskItem> Tasks { get; private set; } = [];
public int TotalCount { get; private set; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public IEnumerable<SelectListItem> StatusOptions { get; private set; } = [];
public IEnumerable<SelectListItem> CategoryOptions { get; private set; } = [];
public TaskService.TaskStats Stats { get; private set; } = new(0, 0, 0, 0);
public async Task OnGetAsync(CancellationToken ct)
{
(Tasks, TotalCount) = await _svc.GetPagedAsync(
Search, Status, CategoryId, Page, PageSize, ct);
Stats = await _svc.GetStatsAsync(ct);
var categories = await _categorySvc.GetAllAsync(ct);
CategoryOptions = categories.Select(c =>
new SelectListItem(c.Name, c.Id.ToString()));
StatusOptions = Enum.GetValues<TaskStatus>()
.Select(s => new SelectListItem(s.GetDisplayText(), s.ToString()));
}
public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
{
var success = await _svc.DeleteAsync(id, ct);
if (success) TempData["SuccessMessage"] = "Задачу видалено";
else TempData["ErrorMessage"] = "Задачу не знайдено";
return RedirectToPage();
}
}
// Extension для enum
public static class TaskStatusExtensions
{
public static string GetDisplayText(this TaskStatus status) => status switch
{
TaskStatus.Todo => "Очікує",
TaskStatus.InProgress => "В процесі",
TaskStatus.Done => "Виконано",
TaskStatus.Cancelled => "Скасовано",
_ => status.ToString()
};
public static string GetBadgeClass(this TaskStatus status) => status switch
{
TaskStatus.Todo => "secondary",
TaskStatus.InProgress => "primary",
TaskStatus.Done => "success",
TaskStatus.Cancelled => "dark",
_ => "light"
};
public static string GetPriorityBadgeClass(this TaskPriority priority) => priority switch
{
TaskPriority.Low => "light text-dark",
TaskPriority.Medium => "warning text-dark",
TaskPriority.High => "danger",
TaskPriority.Critical => "danger",
_ => "secondary"
};
}
@page
@model IndexModel
@using TaskManager.Models
@{
ViewData["Title"] = "Задачі";
}
@* ─── Статистика ────────────────────────────────────────────── *@
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body">
<div class="fs-2 fw-bold text-primary">@Model.Stats.Total</div>
<div class="text-muted small">Всього</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body">
<div class="fs-2 fw-bold text-warning">@Model.Stats.InProgress</div>
<div class="text-muted small">В процесі</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body">
<div class="fs-2 fw-bold text-success">@Model.Stats.Done</div>
<div class="text-muted small">Виконано</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body">
<div class="fs-2 fw-bold text-danger">@Model.Stats.Overdue</div>
<div class="text-muted small">Прострочено</div>
</div>
</div>
</div>
</div>
@* ─── Фільтри ───────────────────────────────────────────────── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-4">
<label asp-for="Search" class="form-label">Пошук</label>
<input asp-for="Search" class="form-control"
placeholder="Назва або опис...">
</div>
<div class="col-md-3">
<label asp-for="Status" class="form-label">Статус</label>
<select asp-for="Status" asp-items="Model.StatusOptions"
class="form-select">
<option value="">Всі статуси</option>
</select>
</div>
<div class="col-md-3">
<label asp-for="CategoryId" class="form-label">Категорія</label>
<select asp-for="CategoryId" asp-items="Model.CategoryOptions"
class="form-select">
<option value="">Всі категорії</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-search me-1"></i>Знайти
</button>
</div>
</form>
</div>
</div>
@* ─── Список задач ──────────────────────────────────────────── *@
@if (!Model.Tasks.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted"></i>
<h4 class="mt-3 text-muted">Задач не знайдено</h4>
<a asp-page="./Create" class="btn btn-primary mt-2">
<i class="bi bi-plus-lg me-1"></i>Створити першу
</a>
</div>
}
else
{
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Задача</th>
<th>Статус</th>
<th>Пріоритет</th>
<th>Категорія</th>
<th>Дедлайн</th>
<th class="text-end">Дії</th>
</tr>
</thead>
<tbody>
@foreach (var task in Model.Tasks)
{
<tr class="@(task.IsOverdue ? "table-danger" : "")">
<td>
<a asp-page="./Details" asp-route-id="@task.Id"
class="fw-semibold text-decoration-none">
@task.Title
</a>
@if (task.IsOverdue)
{
<span class="badge bg-danger ms-1">Прострочено</span>
}
</td>
<td>
<span class="badge bg-@task.Status.GetBadgeClass()">
@task.Status.GetDisplayText()
</span>
</td>
<td>
<span class="badge bg-@task.Priority.GetPriorityBadgeClass()">
@task.Priority
</span>
</td>
<td>
@if (task.Category is not null)
{
<span class="badge"
style="background-color: @task.Category.Color">
@task.Category.Name
</span>
}
</td>
<td>
@if (task.DueDate.HasValue)
{
<span class="@(task.IsOverdue ? "text-danger fw-bold" : "")">
@task.DueDate.Value.ToString("dd.MM.yyyy")
</span>
}
</td>
<td class="text-end">
<a asp-page="./Edit" asp-route-id="@task.Id"
class="btn btn-sm btn-outline-primary me-1">
<i class="bi bi-pencil"></i>
</a>
<form method="post" asp-page-handler="Delete"
style="display: inline">
<input type="hidden" name="id" value="@task.Id">
<button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Видалити «@task.Title»?')">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@* ─── Пагінація ─────────────────────────────────────────── *@
@if (Model.TotalPages > 1)
{
<nav class="mt-3" aria-label="Пагінація">
<ul class="pagination justify-content-center">
@for (int i = 1; i <= Model.TotalPages; i++)
{
<li class="page-item @(i == Model.Page ? "active" : "")">
<a class="page-link"
asp-page="./Index"
asp-route-page="@i"
asp-route-search="@Model.Search"
asp-route-status="@Model.Status"
asp-route-categoryId="@Model.CategoryId">
@i
</a>
</li>
}
</ul>
</nav>
}
}
Крок 5: Створення задачі — Pages/Tasks/Create
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using System.ComponentModel.DataAnnotations;
using TaskManager.Models;
using TaskManager.Services;
namespace TaskManager.Pages.Tasks;
public class CreateModel : PageModel
{
private readonly TaskService _svc;
private readonly CategoryService _categorySvc;
public CreateModel(TaskService svc, CategoryService categorySvc)
{
_svc = svc;
_categorySvc = categorySvc;
}
[BindProperty]
public CreateTaskInput Input { get; set; } = new();
public IEnumerable<SelectListItem> CategoryOptions { get; private set; } = [];
public IEnumerable<SelectListItem> PriorityOptions { get; private set; } = [];
public async Task OnGetAsync(CancellationToken ct)
{
await LoadSelectListsAsync(ct);
}
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
if (!ModelState.IsValid)
{
await LoadSelectListsAsync(ct);
return Page();
}
var task = new TaskItem
{
Title = Input.Title,
Description = Input.Description,
Priority = Input.Priority,
Status = TaskStatus.Todo,
DueDate = Input.DueDate,
CategoryId = Input.CategoryId,
CreatedAt = DateTime.UtcNow
};
await _svc.CreateAsync(task, ct);
TempData["SuccessMessage"] = $"Задачу «{task.Title}» створено!";
return RedirectToPage("./Index");
}
private async Task LoadSelectListsAsync(CancellationToken ct)
{
var categories = await _categorySvc.GetAllAsync(ct);
CategoryOptions = categories.Select(c =>
new SelectListItem(c.Name, c.Id.ToString()));
PriorityOptions = Enum.GetValues<TaskPriority>()
.Select(p => new SelectListItem(p.ToString(), p.ToString()));
}
}
public class CreateTaskInput
{
[Required(ErrorMessage = "Назва задачі обов'язкова")]
[MaxLength(200, ErrorMessage = "Максимум 200 символів")]
[Display(Name = "Назва")]
public string Title { get; set; } = "";
[MaxLength(2000)]
[Display(Name = "Опис")]
public string? Description { get; set; }
[Display(Name = "Пріоритет")]
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
[Display(Name = "Дедлайн")]
[DataType(DataType.Date)]
public DateTime? DueDate { get; set; }
[Display(Name = "Категорія")]
public int? CategoryId { get; set; }
}
@page
@model CreateModel
@using TaskManager.Models
@{
ViewData["Title"] = "Нова задача";
}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="d-flex align-items-center mb-4">
<a asp-page="./Index" class="btn btn-outline-secondary btn-sm me-3">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="mb-0">Нова задача</h1>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form method="post">
<div class="mb-3">
<label asp-for="Input.Title" class="form-label fw-semibold"></label>
<input asp-for="Input.Title" class="form-control form-control-lg"
placeholder="Що потрібно зробити?">
<span asp-validation-for="Input.Title" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Input.Description" class="form-label fw-semibold"></label>
<textarea asp-for="Input.Description" class="form-control"
rows="4" placeholder="Деталі задачі..."></textarea>
<span asp-validation-for="Input.Description" class="text-danger small"></span>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label asp-for="Input.Priority" class="form-label fw-semibold"></label>
<select asp-for="Input.Priority"
asp-items="Model.PriorityOptions"
class="form-select"></select>
</div>
<div class="col-md-4">
<label asp-for="Input.CategoryId" class="form-label fw-semibold"></label>
<select asp-for="Input.CategoryId"
asp-items="Model.CategoryOptions"
class="form-select">
<option value="">— Без категорії —</option>
</select>
</div>
<div class="col-md-4">
<label asp-for="Input.DueDate" class="form-label fw-semibold"></label>
<input asp-for="Input.DueDate" class="form-control" type="date"
min="@DateTime.Today.ToString("yyyy-MM-dd")">
</div>
</div>
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Створити задачу
</button>
<a asp-page="./Index" class="btn btn-outline-secondary">Скасувати</a>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
Крок 6: Деталі і редагування
namespace TaskManager.Pages.Tasks;
public class DetailsModel : PageModel
{
private readonly TaskService _svc;
public DetailsModel(TaskService svc) => _svc = svc;
public TaskItem Task { get; private set; } = null!;
public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
{
var task = await _svc.GetByIdAsync(id, ct);
if (task is null) return NotFound();
Task = task;
return Page();
}
// Зміна статусу прямо зі сторінки деталей
public async Task<IActionResult> OnPostChangeStatusAsync(
int id, TaskStatus newStatus, CancellationToken ct)
{
var task = await _svc.GetByIdAsync(id, ct);
if (task is null) return NotFound();
await _svc.UpdateAsync(id, task with { Status = newStatus }, ct);
TempData["SuccessMessage"] = $"Статус змінено на «{newStatus.GetDisplayText()}»";
return RedirectToPage("./Details", new { id });
}
}
@page "{id:int}"
@model DetailsModel
@using TaskManager.Models
@{
ViewData["Title"] = Model.Task.Title;
}
<div class="d-flex align-items-start mb-4">
<a asp-page="./Index" class="btn btn-outline-secondary btn-sm me-3 mt-1">
<i class="bi bi-arrow-left"></i>
</a>
<div>
<h1 class="mb-1">@Model.Task.Title</h1>
<div class="d-flex gap-2">
<span class="badge bg-@Model.Task.Status.GetBadgeClass()">
@Model.Task.Status.GetDisplayText()
</span>
<span class="badge bg-@Model.Task.Priority.GetPriorityBadgeClass()">
@Model.Task.Priority
</span>
@if (Model.Task.Category is not null)
{
<span class="badge"
style="background-color: @Model.Task.Category.Color">
@Model.Task.Category.Name
</span>
}
@if (Model.Task.IsOverdue)
{
<span class="badge bg-danger">Прострочено</span>
}
</div>
</div>
<div class="ms-auto d-flex gap-2">
<a asp-page="./Edit" asp-route-id="@Model.Task.Id"
class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil me-1"></i>Редагувати
</a>
</div>
</div>
@if (Model.Task.Description is not null)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<p class="mb-0">@Model.Task.Description</p>
</div>
</div>
}
<div class="row g-3 mb-4">
@if (Model.Task.DueDate is not null)
{
<div class="col-auto">
<small class="text-muted">Дедлайн</small>
<div class="@(Model.Task.IsOverdue ? "text-danger fw-bold" : "")">
@Model.Task.DueDate.Value.ToString("dd MMMM yyyy")
</div>
</div>
}
<div class="col-auto">
<small class="text-muted">Створено</small>
<div>@Model.Task.CreatedAt.ToString("dd.MM.yyyy HH:mm")</div>
</div>
@if (Model.Task.CompletedAt is not null)
{
<div class="col-auto">
<small class="text-muted">Виконано</small>
<div>@Model.Task.CompletedAt.Value.ToString("dd.MM.yyyy HH:mm")</div>
</div>
}
</div>
@* Швидка зміна статусу *@
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="card-title">Змінити статус</h6>
<div class="d-flex flex-wrap gap-2">
@foreach (var status in Enum.GetValues<TaskStatus>())
{
if (status != Model.Task.Status)
{
<form method="post" asp-page-handler="ChangeStatus">
<input type="hidden" name="newStatus" value="@status">
<button type="submit"
class="btn btn-sm btn-outline-@status.GetBadgeClass()">
@status.GetDisplayText()
</button>
</form>
}
}
</div>
</div>
</div>
Крок 7: Запуск і перевірка
dotnet run
Відкрийте https://localhost:{port}/tasks:
- ✅ Список задач з фільтрами і пагінацією
- ✅ Кнопка "Нова задача" → форма з валідацією
- ✅ При порожній формі → client-side validation (без POST)
- ✅ При правильних даних → задача з'являється у списку
- ✅ Редагування з передзаповненою формою
- ✅ Деталі: зміна статусу через кнопки
- ✅ Видалення з підтвердженням
Практичні завдання для розширення
Рівень 1 — Обов'язкові доповнення
Завдання 1.1. Реалізуйте Pages/Tasks/Delete.cshtml — окрему сторінку підтвердження видалення (а не confirm() JavaScript). OnGet(int id) — завантажує задачу, OnPost(int id) — видаляє і редиректить.
Завдання 1.2. Реалізуйте Pages/Categories/Index.cshtml і Pages/Categories/Create.cshtml за аналогією з задачами. Категорія має Name (Required, MaxLength 50) і Color (hex кольору для badge).
Рівень 2 — Функціональність
Завдання 2.1. Додайте масове маркування виконаними: на сторінці списку — checkbox перед кожною задачею і кнопка "Виконати обрані". OnPostCompleteSelectedAsync() з [BindProperty] int[] SelectedIds. Задачі зі Status = Done — сірі і не мають checkbox.
Завдання 2.2. Додайте субзадачі: клас SubTask(int Id, string Title, bool Done, int TaskItemId). На Details.cshtml — список субзадач з можливістю додавання нової (inline-форма) і маркування виконаною (checkbox з POST через page handler OnPostToggleSubTaskAsync(int subTaskId)).
Рівень 3 — Архітектура
Завдання 3.1. Реалізуйте експорт у CSV: endpoint GET /tasks/export у OnGetExportAsync() (Page Handler) що повертає FileContentResult з CSV файлом всіх задач. Кнопка "Завантажити CSV" на сторінці списку. Формат: Id,Title,Status,Priority,DueDate,Category.
Завдання 3.2. Додайте View Component RecentTasksViewComponent що показує 5 останніх задач. Зареєструйте як {Component("RecentTasks")} у _Layout.cshtml (в sidebar). View Component — окремий клас InvokeAsync() і partial _RecentTasksViewComponent.cshtml.
Форми і валідація: повний цикл обробки даних
Повний цикл форм у Razor Pages: HTML form з anti-forgery token, DataAnnotations для визначення правил, client-side validation через jQuery Validate Unobtrusive, server-side ModelState, IFormFile для завантаження файлів, обробка кількох кнопок submit, AJAX-форми через fetch API.
C# & .NET: The Ultimate Roadmap
Цей план є детальним путівником по екосистемі C#. Він побудований за принципом Stack-Centric ("Від Ядра до Сфер") і розбитий на атомарні теми для послідовного вивчення.