Razor Pages

Практичний проєкт: TaskManager на Razor Pages

Повний практичний CRUD-проєкт TaskManager на Razor Pages: список задач з пошуком і пагінацією, форми створення та редагування з валідацією, підтвердження видалення, категорії, пріоритети, статуси, спільний Layout з навігацією, Partial Views, IMemoryCache, EF Core з PostgreSQL.

Практичний проєкт: TaskManager на Razor Pages

Цей проєкт — ваш повний CRUD-застосунок на Razor Pages. Ми будуємо менеджер задач з категоріями, пріоритетами і статусами. Кожен крок наростає на попередньому, і наприкінці ви матимете повноцінний working застосунок.

Єдина мета цього проєкту — практика Razor Pages на знайомих концепціях. EF Core, DI, конфігурація — вже відомі, тому пояснюємо тільки Razor Pages-специфіку.

Структура проєкту

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
Models/Enums.cs
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
}
Models/Category.cs
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; } = [];
}
Models/TaskItem.cs
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; }
}
Data/AppDbContext.cs
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
            }
        );
    }
}
Program.cs
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: Сервіси

Services/TaskService.cs
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

Pages/Shared/_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

Pages/Tasks/Index.cshtml.cs
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"
    };
}
Pages/Tasks/Index.cshtml
@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

Pages/Tasks/Create.cshtml.cs
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; }
}
Pages/Tasks/Create.cshtml
@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: Деталі і редагування

Pages/Tasks/Details.cshtml.cs
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 });
    }
}
Pages/Tasks/Details.cshtml
@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:

  1. ✅ Список задач з фільтрами і пагінацією
  2. ✅ Кнопка "Нова задача" → форма з валідацією
  3. ✅ При порожній формі → client-side validation (без POST)
  4. ✅ При правильних даних → задача з'являється у списку
  5. ✅ Редагування з передзаповненою формою
  6. ✅ Деталі: зміна статусу через кнопки
  7. ✅ Видалення з підтвердженням

Практичні завдання для розширення

Рівень 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.

Copyright © 2026