Initial commit: Tasks.Module

This commit is contained in:
Saidamin Kattan
2026-08-07 02:17:32 +03:00
commit 46aa82d0c7
97 changed files with 7443 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
@page "/tasks/categories"
@using Tasks.Module.Models
@using Tasks.Module.Services
@using Tasks.Module.Components.Dialogs
@inject ICategoryService CategoryService
@inject IDialogService Dialog
@inject ISnackbar Snackbar
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div class="max-w-4xl mx-auto py-8 px-4">
@* Header *@
<div class="mb-8">
<h1 class="text-3xl font-bold text-slate-800">Categories</h1>
<p class="text-slate-500 mt-1">Manage your task categories</p>
</div>
@* Toolbar *@
<div class="bg-white rounded-2xl shadow-md p-4 mb-4">
<div class="d-flex align-center gap-3">
<MudTextField T="string"
@bind-Value:get="searchText"
@bind-Value:set="OnSearchChanged"
Placeholder="Search categories..."
DisableUnderLine="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Style="max-width: 300px;" />
<MudSpacer />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="AddRootCategory">
Add Category
</MudButton>
</div>
</div>
@* Categories Tree *@
@if (isLoading)
{
<div class="flex justify-center py-12">
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
</div>
}
else if (filteredCategories.Count == 0 && string.IsNullOrEmpty(searchText))
{
<div class="bg-white rounded-2xl shadow-lg p-12 text-center">
<MudIcon Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Large" Color="Color.Default" Class="mb-4" />
<h3 class="text-lg font-semibold text-slate-700">No categories yet</h3>
<p class="text-slate-500 mt-2">Click "Add Category" to create your first category</p>
</div>
}
else
{
<div class="bg-white rounded-2xl shadow-lg">
@foreach (var category in filteredCategories)
{
<CategoryTreeItem Category="category"
OnAddChild="AddChildCategory"
OnDelete="DeleteCategory"
OnUpdate="UpdateCategory"
SearchText="searchText" />
}
</div>
}
</div>
</div>
@code {
private List<CategoryDto> categories = new();
private List<CategoryDto> filteredCategories = new();
private bool isLoading = true;
private string searchText = string.Empty;
protected override async Task OnInitializedAsync()
{
await LoadCategories();
}
private async Task LoadCategories()
{
isLoading = true;
try
{
categories = await CategoryService.GetHierarchyAsync();
ApplyFilter();
}
finally
{
isLoading = false;
}
}
private void ApplyFilter()
{
if (string.IsNullOrWhiteSpace(searchText))
{
filteredCategories = categories;
}
else
{
filteredCategories = FilterTree(categories, searchText.ToLower());
}
}
private List<CategoryDto> FilterTree(List<CategoryDto> cats, string search)
{
var result = new List<CategoryDto>();
foreach (var cat in cats)
{
var filteredChildren = FilterTree(cat.Children, search);
if (cat.Name.ToLower().Contains(search) || filteredChildren.Count > 0)
{
var copy = new CategoryDto
{
Id = cat.Id,
Name = cat.Name,
Description = cat.Description,
ParentId = cat.ParentId,
CreatedAt = cat.CreatedAt,
UpdatedAt = cat.UpdatedAt,
Children = filteredChildren
};
result.Add(copy);
}
}
return result;
}
private void OnSearchChanged(string value)
{
searchText = value;
ApplyFilter();
}
private async Task AddRootCategory()
{
await AddCategory(null);
}
private async Task AddCategory(string? parentId)
{
var dto = new CreateCategoryDto { ParentId = parentId };
var parameters = new DialogParameters<CreateCategoryDialog>
{
{ x => x.Category, dto },
{ x => x.IsChild, parentId != null }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateCategoryDialog>("Add Category", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
try
{
await CategoryService.CreateAsync(dto);
await LoadCategories();
Snackbar.Add("Category created", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
}
}
}
private async Task AddChildCategory(CategoryDto parent)
{
await AddCategory(parent.Id);
}
private async Task DeleteCategory(CategoryDto category)
{
var childCount = category.Children.Count;
var (success, hasChildren, _, _) = await CategoryService.DeleteAsync(category.Id, false);
if (!success && hasChildren)
{
var confirmed = await Dialog.ShowMessageBox(
"Delete Category",
$"\"{category.Name}\" has {childCount} child category(ries). Delete all?",
yesText: "Delete All", cancelText: "Cancel");
if (confirmed == true)
{
var result = await CategoryService.DeleteAsync(category.Id, true);
if (result.success)
{
Snackbar.Add("Category deleted", Severity.Success);
await LoadCategories();
}
}
}
else if (success)
{
Snackbar.Add("Category deleted", Severity.Success);
await LoadCategories();
}
}
private async Task UpdateCategory(CategoryDto category)
{
var dto = new UpdateCategoryDto
{
Id = category.Id,
Name = category.Name,
Description = category.Description,
ParentId = category.ParentId
};
var parameters = new DialogParameters<CreateCategoryDialog>
{
{ x => x.Category, dto },
{ x => x.IsUpdate, true },
{ x => x.IsChild, category.ParentId != null }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateCategoryDialog>("Update Category", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
try
{
await CategoryService.UpdateAsync(dto);
Snackbar.Add("Category updated", Severity.Success);
await LoadCategories();
}
catch (Exception ex)
{
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
}
}
}
}
+113
View File
@@ -0,0 +1,113 @@
@using Tasks.Module.Models
@using Tasks.Module.Services
<MudExpansionPanel @bind-IsExpanded="_expanded" Class="category-item">
<TitleContent>
<div class="d-flex align-center justify-space-between" style="width: 100%;">
<div class="d-flex align-center">
@if (Category.Children.Count > 0)
{
<span class="expand-chevron" style="cursor: pointer; padding: 4px;"
@onclick:stopPropagation="true"
@onclick="@(() => { _expanded = !_expanded; })">
<MudIcon Icon="@(_expanded ? Icons.Material.Filled.KeyboardArrowDown : Icons.Material.Filled.KeyboardArrowRight)"
Size="Size.Small"
Class="mr-1"
Style="width: 20px; display: block;" />
</span>
}
else
{
<div style="width: 32px;"></div>
}
<MudIcon Icon="@Icons.Material.Filled.Folder" Color="Color.Default" Class="mr-2" />
<span class="task-title">@HighlightText(Category.Name)</span>
</div>
<div class="d-flex align-center gap-1">
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mr-2">
@Category.Children.Count children
</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Add"
Size="Size.Small"
Title="Add sub-category"
OnClick="() => OnAddChild.InvokeAsync(Category)" />
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Title="Edit"
OnClick="() => OnUpdate.InvokeAsync(Category)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
Title="Delete"
OnClick="() => OnDelete.InvokeAsync(Category)" />
</div>
</div>
</TitleContent>
<ChildContent>
<div class="pl-4">
@foreach (var child in Category.Children)
{
<CategoryTreeItem Category="child"
OnAddChild="OnAddChild"
OnDelete="OnDelete"
OnUpdate="OnUpdate"
SearchText="SearchText" />
}
</div>
</ChildContent>
</MudExpansionPanel>
@code {
[Parameter]
public CategoryDto Category { get; set; } = new();
[Parameter]
public EventCallback<CategoryDto> OnAddChild { get; set; }
[Parameter]
public EventCallback<CategoryDto> OnDelete { get; set; }
[Parameter]
public EventCallback<CategoryDto> OnUpdate { get; set; }
[Parameter]
public string SearchText { get; set; } = string.Empty;
private bool _expanded = false;
private RenderFragment HighlightText(string text)
{
if (string.IsNullOrEmpty(SearchText))
{
return @<span>@text</span>;
}
var parts = text.Split(new[] { SearchText }, StringSplitOptions.None);
return@<text>
@{
for (int i = 0; i < parts.Length; i++)
{
@parts[i]
if (i < parts.Length - 1)
{
<mark class="search-highlight">@SearchText</mark>
}
}
}
</text>;
}
}
<style>
.category-item {
border-bottom: 1px solid var(--mud-palette-lines-default);
}
.category-item:last-child {
border-bottom: none;
}
.search-highlight {
background-color: yellow;
padding: 0 2px;
}
</style>
+208
View File
@@ -0,0 +1,208 @@
@page "/tasks/tags"
@using Tasks.Module.Models
@using Tasks.Module.Services
@using Tasks.Module.Components.Dialogs
@inject ITagService TagService
@inject IDialogService Dialog
@inject ISnackbar Snackbar
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div class="max-w-4xl mx-auto py-8 px-4">
@* Header *@
<div class="mb-8">
<h1 class="text-3xl font-bold text-slate-800">Tags</h1>
<p class="text-slate-500 mt-1">Manage your task tags</p>
</div>
@* Toolbar *@
<div class="bg-white rounded-2xl shadow-md p-4 mb-4">
<div class="d-flex align-center gap-3">
<MudTextField T="string"
@bind-Value:get="searchText"
@bind-Value:set="OnSearchChanged"
Placeholder="Search tags..."
DisableUnderLine="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Style="max-width: 300px;" />
<MudSpacer />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="AddTag">
Add Tag
</MudButton>
</div>
</div>
@* Tags Grid *@
@if (isLoading)
{
<div class="flex justify-center py-12">
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
</div>
}
else if (filteredTags.Count == 0 && string.IsNullOrEmpty(searchText))
{
<div class="bg-white rounded-2xl shadow-lg p-12 text-center">
<MudIcon Icon="@Icons.Material.Filled.LocalOffer" Size="Size.Large" Color="Color.Default" Class="mb-4" />
<h3 class="text-lg font-semibold text-slate-700">No tags yet</h3>
<p class="text-slate-500 mt-2">Click "Add Tag" to create your first tag</p>
</div>
}
else if (filteredTags.Count == 0)
{
<div class="bg-white rounded-2xl shadow-lg p-12 text-center">
<MudIcon Icon="@Icons.Material.Filled.SearchOff" Size="Size.Large" Color="Color.Default" Class="mb-4" />
<h3 class="text-lg font-semibold text-slate-700">No tags found</h3>
<p class="text-slate-500 mt-2">Try a different search term</p>
</div>
}
else
{
<div class="bg-white rounded-2xl shadow-lg p-6">
<div class="d-flex flex-wrap gap-3">
@foreach (var tag in filteredTags)
{
<MudChip T="string"
Color="Color.Secondary"
Size="Size.Medium"
OnClick="() => EditTag(tag)"
OnClose="() => DeleteTag(tag)">
@(string.IsNullOrEmpty(tag.Color) ? "" : "● ")
@tag.Name
</MudChip>
}
</div>
</div>
}
</div>
</div>
@code {
private List<TagDto> tags = new();
private List<TagDto> filteredTags = new();
private bool isLoading = true;
private string searchText = string.Empty;
protected override async Task OnInitializedAsync()
{
await LoadTags();
}
private async Task LoadTags()
{
isLoading = true;
try
{
tags = await TagService.GetAllAsync();
ApplyFilter();
}
finally
{
isLoading = false;
}
}
private void ApplyFilter()
{
if (string.IsNullOrWhiteSpace(searchText))
{
filteredTags = tags;
}
else
{
filteredTags = tags.Where(t => t.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase)).ToList();
}
}
private void OnSearchChanged(string value)
{
searchText = value;
ApplyFilter();
}
private async Task AddTag()
{
var dto = new CreateTagDto();
var parameters = new DialogParameters<CreateTagDialog>
{
{ x => x.Tag, dto }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTagDialog>("Add Tag", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
try
{
await TagService.CreateAsync(dto);
Snackbar.Add("Tag created", Severity.Success);
await LoadTags();
}
catch (Exception ex)
{
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
}
}
}
private async Task EditTag(TagDto tag)
{
var dto = new UpdateTagDto
{
Id = tag.Id,
Name = tag.Name,
Color = tag.Color
};
var parameters = new DialogParameters<CreateTagDialog>
{
{ x => x.Tag, dto },
{ x => x.IsUpdate, true }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTagDialog>("Edit Tag", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
try
{
await TagService.UpdateAsync(dto);
Snackbar.Add("Tag updated", Severity.Success);
await LoadTags();
}
catch (Exception ex)
{
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
}
}
}
private async Task DeleteTag(TagDto tag)
{
var confirmed = await Dialog.ShowMessageBox(
"Delete Tag",
$"Delete \"{tag.Name}\"? It will be removed from all tasks.",
yesText: "Delete", cancelText: "Cancel");
if (confirmed == true)
{
try
{
await TagService.DeleteAsync(tag.Id);
Snackbar.Add("Tag deleted", Severity.Success);
await LoadTags();
}
catch (Exception ex)
{
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
}
}
}
}
+263
View File
@@ -0,0 +1,263 @@
@page "/tasks/taskssheet/{tasksSheetId}"
@using Tasks.Module.Models
@using Tasks.Module.Services
@inject IFolderService FolderService
@inject ITodoService TodoService
@inject IDialogService Dialog
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div class="max-w-4xl mx-auto py-8 px-4">
@* Header *@
<div class="mb-8">
<MudButton StartIcon="@Icons.Material.Filled.ArrowBack" Href="/tasks" Color="Color.Default" Class="mb-4">
Back to Tasks
</MudButton>
<h1 class="text-3xl font-bold text-slate-800">@_tasksSheetName</h1>
<p class="text-slate-500 mt-1">Manage tasks in this sheet</p>
</div>
@* Quick Add Input *@
<div class="bg-white rounded-2xl shadow-lg p-4 mb-6">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
<MudIcon Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Size="Size.Small" />
</div>
<MudTextField T="string"
@bind-Value:get="quickAddTitle"
@bind-Value:set="(v) => { quickAddTitle = v; StateHasChanged(); }"
Placeholder="Add a task... (press Enter)"
DisableUnderLine="true"
Class="flex-grow"
Adornment="Adornment.None"
OnKeyDown="HandleQuickAdd" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="QuickAddTask"
Class="rounded-xl">
Add
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenCreateDialog"
Class="rounded-xl">
Add Task
</MudButton>
</div>
</div>
@* Tasks List *@
@if (_loading)
{
<div class="flex justify-center py-12">
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
</div>
}
else if (!_tasksSheetFound)
{
<div class="bg-white rounded-2xl shadow-lg p-12 text-center">
<h3 class="text-lg font-semibold text-slate-700">Tasks sheet not found</h3>
<MudButton Href="/tasks" Color="Color.Primary" Class="mt-4">Go to Tasks</MudButton>
</div>
}
else
{
<div class="space-y-3">
@foreach (var task in _tasks)
{
<TaskCard Task="task"
OnToggle="ToggleTask"
OnDelete="DeleteTask"
OnUpdate="OpenUpdateDialog"
OnAddChild="OpenAddChildDialog"
CollapsedTasks="collapsedTasks"
ToggleCollapse="ToggleCollapse" />
}
</div>
}
</div>
</div>
@code {
[Parameter] public string tasksSheetId { get; set; } = string.Empty;
private string _tasksSheetName = string.Empty;
private bool _loading = true;
private bool _tasksSheetFound = false;
private CreateTodoItemDto editTask = new();
private string currentTaskId = string.Empty;
private string quickAddTitle = string.Empty;
private HashSet<string> collapsedTasks = new();
private List<TodoItemDto> _tasks = new();
private string _lastTasksSheetId = string.Empty;
protected override async Task OnInitializedAsync()
{
await LoadTasksSheet();
}
protected override async Task OnParametersSetAsync()
{
if (tasksSheetId != _lastTasksSheetId)
{
_lastTasksSheetId = tasksSheetId;
collapsedTasks.Clear();
await LoadTasksSheet();
}
}
private async Task LoadTasksSheet()
{
_loading = true;
try
{
var tasksSheet = await FolderService.GetTasksSheetByIdAsync(tasksSheetId);
if (tasksSheet != null)
{
_tasksSheetName = tasksSheet.Title;
_tasks = tasksSheet.Tasks;
_tasksSheetFound = true;
}
else
{
_tasksSheetFound = false;
}
}
finally
{
_loading = false;
}
}
private async Task HandleQuickAdd(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(quickAddTitle))
{
await QuickAddTask();
}
}
private async Task QuickAddTask()
{
if (string.IsNullOrWhiteSpace(quickAddTitle))
return;
var newTask = new CreateTodoItemDto
{
Title = quickAddTitle.Trim(),
Priority = 0,
ParentId = null
};
await FolderService.AddTaskToTasksSheetAsync(tasksSheetId, newTask);
quickAddTitle = string.Empty;
await LoadTasksSheet();
}
private void ToggleCollapse(string taskId)
{
if (collapsedTasks.Contains(taskId))
collapsedTasks.Remove(taskId);
else
collapsedTasks.Add(taskId);
}
private async Task ToggleTask(string id)
{
await FolderService.ToggleTaskInTasksSheetAsync(id);
await LoadTasksSheet();
}
private async Task DeleteTask(string id)
{
await FolderService.DeleteTaskInTasksSheetAsync(id);
await LoadTasksSheet();
}
private async Task OpenCreateDialog()
{
editTask = new CreateTodoItemDto();
var parameters = new DialogParameters<CreateTaskDialog>
{
{ x => x.Task, editTask },
{ x => x.IsUpdate, false }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTaskDialog>("Create Task", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
await FolderService.AddTaskToTasksSheetAsync(tasksSheetId, editTask);
await LoadTasksSheet();
}
}
private async Task OpenAddChildDialog(TodoItemDto parentTask)
{
editTask = new CreateTodoItemDto { ParentId = parentTask.Id };
var parameters = new DialogParameters<CreateTaskDialog>
{
{ x => x.Task, editTask },
{ x => x.IsUpdate, false },
{ x => x.IsChild, true },
{ x => x.ParentTitle, parentTask.Title }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTaskDialog>("Add Sub-task", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
await LoadTasksSheet();
}
}
private async Task OpenUpdateDialog(TodoItemDto task)
{
currentTaskId = task.Id;
editTask = new CreateTodoItemDto
{
Title = task.Title,
Description = task.Description,
Priority = task.Priority,
DueDate = task.DueDate,
ParentId = task.ParentId,
CategoryId = task.CategoryId,
TagIds = task.TagIds.ToList(),
FileId = task.FileId
};
var parameters = new DialogParameters<CreateTaskDialog>
{
{ x => x.Task, editTask },
{ x => x.IsUpdate, true }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTaskDialog>("Update Task", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
await TodoService.UpdateTodoAsync(new UpdateTodoItemDto
{
Id = currentTaskId,
Title = editTask.Title,
Description = editTask.Description,
Priority = editTask.Priority,
DueDate = editTask.DueDate,
IsCompleted = task.IsCompleted,
ParentId = editTask.ParentId,
CategoryId = editTask.CategoryId,
TagIds = editTask.TagIds,
FileId = editTask.FileId
});
await LoadTasksSheet();
}
}
}
+389
View File
@@ -0,0 +1,389 @@
@page "/tasks"
@using Tasks.Module.Models
@using Tasks.Module.Services
@using Tasks.Module.Components.Dialogs
@inject ITodoService TodoService
@inject IDialogService Dialog
@inject IViewService ViewService
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div class="max-w-4xl mx-auto py-8 px-4">
@* Header *@
<div class="mb-8">
<h1 class="text-3xl font-bold text-slate-800">My Tasks</h1>
<p class="text-slate-500 mt-1">Manage your tasks and sub-tasks</p>
</div>
@* Search and Filters *@
<div class="bg-white rounded-2xl shadow-md p-4 mb-4">
<div class="flex flex-wrap items-center gap-3">
<MudTextField T="string"
@bind-Value:get="searchText"
@bind-Value:set="OnSearchChanged"
Placeholder="Search tasks..."
DisableUnderLine="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Class="flex-grow"
Style="max-width: 400px;" />
<MudButton Variant="Variant.Text"
Color="Color.Default"
StartIcon="@(_showFilters ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
OnClick="ToggleFilters"
Class="rounded">
@(_showFilters ? "Hide Filters" : "Show Filters")
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Default"
StartIcon="@Icons.Material.Filled.Add"
OnClick="CreateView"
Class="rounded">
Add View
</MudButton>
</div>
@if (_showFilters)
{
<div class="flex flex-wrap items-center gap-3 mt-3">
<MudSelect T="int?" Label="Priority" Value="selectedPriority" ValueChanged="OnPriorityChanged" Clearable="true" Class="min-w-[140px]">
<MudSelectItem T="int?" Value="null">All</MudSelectItem>
<MudSelectItem T="int?" Value="0">
<div class="flex items-center gap-2">
<span class="w-2 h-2 bg-green-500 rounded-full"></span> Low
</div>
</MudSelectItem>
<MudSelectItem T="int?" Value="1">
<div class="flex items-center gap-2">
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span> Medium
</div>
</MudSelectItem>
<MudSelectItem T="int?" Value="2">
<div class="flex items-center gap-2">
<span class="w-2 h-2 bg-red-500 rounded-full"></span> High
</div>
</MudSelectItem>
</MudSelect>
<MudSelect T="bool?" Label="Status" Value="selectedCompleted" ValueChanged="OnCompletedChanged" Clearable="true" Class="min-w-[140px]">
<MudSelectItem T="bool?" Value="null">All</MudSelectItem>
<MudSelectItem T="bool?" Value="false">
<div class="flex items-center gap-2">
<MudIcon Icon="@Icons.Material.Filled.Pending" Size="Size.Small" Color="Color.Warning" />
Pending
</div>
</MudSelectItem>
<MudSelectItem T="bool?" Value="true">
<div class="flex items-center gap-2">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Size="Size.Small" Color="Color.Success" />
Completed
</div>
</MudSelectItem>
</MudSelect>
</div>
}
</div>
@* Quick Add Input *@
<div class="bg-white rounded-2xl shadow-lg p-4 mb-6">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
<MudIcon Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Size="Size.Small" />
</div>
<MudTextField T="string"
@bind-Value:get="quickAddTitle"
@bind-Value:set="(v) => { quickAddTitle = v; StateHasChanged(); }"
Placeholder="Add a task quickly... (press Enter)"
DisableUnderLine="true"
Class="flex-grow"
Adornment="Adornment.None"
OnKeyDown="HandleQuickAdd" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="QuickAddTask"
Class="rounded-xl">
Add
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenCreateDialog"
Class="rounded-xl">
Add Task
</MudButton>
</div>
</div>
@* Stats *@
@if (!isLoading && tasks.Any())
{
<div class="flex items-center gap-4 mb-4 text-sm">
<span class="text-slate-500">
@tasks.Count task@(tasks.Count != 1 ? "s" : "")
</span>
@{
var completed = tasks.Count(t => t.IsCompleted);
var pending = tasks.Count - completed;
}
@if (pending > 0)
{
<span class="px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs">@pending pending</span>
}
@if (completed > 0)
{
<span class="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">@completed completed</span>
}
</div>
}
@* Tasks List *@
@if (isLoading)
{
<div class="flex justify-center py-12">
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
</div>
}
else if (tasks.Count == 0)
{
<div class="bg-white rounded-2xl shadow-lg p-12 text-center border border-slate-100">
<div class="w-20 h-20 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<MudIcon Icon="@Icons.Material.Filled.Assignment" Size="Size.Large" Color="Color.Default" />
</div>
<h3 class="text-lg font-semibold text-slate-700">No tasks yet</h3>
<p class="text-slate-500 mt-2">Type a task above and press Enter to get started</p>
</div>
}
else
{
<div class="space-y-3">
@foreach (var task in tasks)
{
<TaskCard Task="task"
OnToggle="ToggleTask"
OnDelete="DeleteTask"
OnUpdate="OpenUpdateDialog"
OnAddChild="OpenAddChildDialog"
CollapsedTasks="collapsedTasks"
ToggleCollapse="ToggleCollapse" />
}
</div>
}
</div>
</div>
@code {
private List<TodoItemDto> tasks = new();
private List<TodoItemDto> allTasks = new();
private bool isLoading = true;
private int? selectedPriority;
private bool? selectedCompleted;
private CreateTodoItemDto editTask = new();
private string currentTaskId = string.Empty;
private string quickAddTitle = string.Empty;
private HashSet<string> collapsedTasks = new();
private string searchText = string.Empty;
private bool _showFilters = false;
private void ToggleFilters()
{
_showFilters = !_showFilters;
}
protected override async Task OnInitializedAsync()
{
await LoadTasks();
}
private async Task LoadTasks()
{
isLoading = true;
try
{
var filter = new TodoFilterDto
{
Priority = selectedPriority,
IsCompleted = selectedCompleted
};
allTasks = await TodoService.GetAllTodosAsync(filter);
ApplyFilters();
}
finally
{
isLoading = false;
}
}
private void ApplyFilters()
{
var filtered = allTasks.AsEnumerable();
if (!string.IsNullOrWhiteSpace(searchText))
{
filtered = filtered.Where(t =>
t.Title.Contains(searchText, StringComparison.OrdinalIgnoreCase) ||
(t.Description?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false));
}
tasks = filtered.ToList();
}
private void OnSearchChanged(string value)
{
searchText = value;
ApplyFilters();
}
private async Task HandleQuickAdd(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(quickAddTitle))
{
await QuickAddTask();
}
}
private async Task QuickAddTask()
{
if (string.IsNullOrWhiteSpace(quickAddTitle))
return;
var newTask = new CreateTodoItemDto
{
Title = quickAddTitle.Trim(),
Priority = 0
};
await TodoService.CreateTodoAsync(newTask);
quickAddTitle = string.Empty;
await LoadTasks();
}
private void ToggleCollapse(string taskId)
{
if (collapsedTasks.Contains(taskId))
collapsedTasks.Remove(taskId);
else
collapsedTasks.Add(taskId);
}
private async Task OnPriorityChanged(int? value)
{
selectedPriority = value;
await LoadTasks();
}
private async Task OnCompletedChanged(bool? value)
{
selectedCompleted = value;
await LoadTasks();
}
private async Task ToggleTask(string id)
{
await TodoService.ToggleCompleteAsync(id);
await LoadTasks();
}
private async Task DeleteTask(string id)
{
await TodoService.DeleteTodoAsync(id);
await LoadTasks();
}
private async Task OpenCreateDialog()
{
editTask = new CreateTodoItemDto();
var parameters = new DialogParameters<CreateTaskDialog>
{
{ x => x.Task, editTask },
{ x => x.IsUpdate, false }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTaskDialog>("Create Task", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
await TodoService.CreateTodoAsync(editTask);
await LoadTasks();
}
}
private async Task OpenAddChildDialog(TodoItemDto parentTask)
{
editTask = new CreateTodoItemDto { ParentId = parentTask.Id };
var parameters = new DialogParameters<CreateTaskDialog>
{
{ x => x.Task, editTask },
{ x => x.IsUpdate, false },
{ x => x.IsChild, true },
{ x => x.ParentTitle, parentTask.Title }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTaskDialog>("Add Sub-task", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
await TodoService.CreateTodoAsync(editTask);
await LoadTasks();
}
}
private async Task OpenUpdateDialog(TodoItemDto task)
{
currentTaskId = task.Id;
editTask = new CreateTodoItemDto
{
Title = task.Title,
Description = task.Description,
Priority = task.Priority,
DueDate = task.DueDate,
ParentId = task.ParentId,
CategoryId = task.CategoryId,
TagIds = task.TagIds.ToList(),
FileId = task.FileId
};
var parameters = new DialogParameters<CreateTaskDialog>
{
{ x => x.Task, editTask },
{ x => x.IsUpdate, true }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTaskDialog>("Update Task", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
await TodoService.UpdateTodoAsync(new UpdateTodoItemDto
{
Id = currentTaskId,
Title = editTask.Title,
Description = editTask.Description,
Priority = editTask.Priority,
DueDate = editTask.DueDate,
IsCompleted = task.IsCompleted,
ParentId = editTask.ParentId,
CategoryId = editTask.CategoryId,
TagIds = editTask.TagIds,
FileId = editTask.FileId
});
await LoadTasks();
}
}
private async Task CreateView()
{
var parameters = new DialogParameters<CreateViewDialog>();
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateViewDialog>("Create View", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false } && result.Data is CreateTaskViewDto createDto)
{
await ViewService.CreateViewAsync(createDto);
}
}
}
+399
View File
@@ -0,0 +1,399 @@
@page "/tasks/view/{viewId}"
@using Tasks.Module.Models
@using Tasks.Module.Services
@inject IViewService ViewService
@inject ITodoService TodoService
@inject IDialogService Dialog
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div class="max-w-4xl mx-auto py-8 px-4">
@* Header *@
<div class="mb-8">
<MudButton StartIcon="@Icons.Material.Filled.ArrowBack" Href="/tasks" Color="Color.Default" Class="mb-4">
Back to Tasks
</MudButton>
<h1 class="text-3xl font-bold text-slate-800">@_viewName</h1>
<p class="text-slate-500 mt-1">Tasks matching this view's criteria</p>
</div>
@* View Criteria Summary *@
@if (_view != null && HasCriteria(_view))
{
<div class="bg-indigo-50 rounded-2xl p-4 mb-6 border border-indigo-100">
<div class="d-flex align-center mb-2">
<MudIcon Icon="@Icons.Material.Filled.FilterList" Color="Color.Primary" Class="mr-2" />
<MudText Typo="Typo.subtitle2" Color="Color.Primary">View Criteria</MudText>
</div>
<div class="d-flex flex-wrap gap-2">
@if (_view.Priority.HasValue)
{
<MudChip Size="Size.Small" T="string" Class="@GetPriorityChipClass(_view.Priority.Value)">
Priority: @GetPriorityLabel(_view.Priority.Value)
</MudChip>
}
@if (_view.IsCompleted.HasValue)
{
<MudChip Size="Size.Small" T="string" Color="@(_view.IsCompleted.Value ? Color.Success : Color.Warning)">
Status: @(_view.IsCompleted.Value ? "Completed" : "Pending")
</MudChip>
}
@if (!string.IsNullOrWhiteSpace(_view.ContainsText))
{
<MudChip Size="Size.Small" T="string" Color="Color.Info">
Contains: "@_view.ContainsText"
</MudChip>
}
@if (_view.DueDateFrom.HasValue)
{
<MudChip Size="Size.Small" T="string" Color="Color.Default">
Due from: @_view.DueDateFrom.Value.ToString("MMM dd")
</MudChip>
}
@if (_view.DueDateTo.HasValue)
{
<MudChip Size="Size.Small" T="string" Color="Color.Default">
Due to: @_view.DueDateTo.Value.ToString("MMM dd")
</MudChip>
}
</div>
</div>
}
@* Quick Add Input - Creates task with view criteria *@
<div class="bg-white rounded-2xl shadow-lg p-4 mb-6">
@if (!string.IsNullOrWhiteSpace(_view?.ContainsText))
{
<MudAlert Severity="Severity.Info" Class="mb-3" Icon="@Icons.Material.Filled.Info">
New tasks will be checked for text: "@_view.ContainsText"
</MudAlert>
}
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
<MudIcon Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Size="Size.Small" />
</div>
<MudTextField T="string"
@bind-Value:get="quickAddTitle"
@bind-Value:set="(v) => { quickAddTitle = v; StateHasChanged(); }"
Placeholder="Add task with view criteria... (press Enter)"
DisableUnderLine="true"
Class="flex-grow"
Adornment="Adornment.None"
OnKeyDown="HandleQuickAdd" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="QuickAddTask"
Class="rounded-xl">
Add
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenCreateDialog"
Class="rounded-xl">
Add Task
</MudButton>
</div>
</div>
@* Stats *@
@if (!isLoading && tasks.Any())
{
<div class="flex items-center gap-4 mb-4 text-sm">
<span class="text-slate-500">
@tasks.Count task@(tasks.Count != 1 ? "s" : "")
</span>
@{
var completed = tasks.Count(t => t.IsCompleted);
var pending = tasks.Count - completed;
}
@if (pending > 0)
{
<span class="px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs">@pending pending</span>
}
@if (completed > 0)
{
<span class="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">@completed completed</span>
}
</div>
}
@* Tasks List *@
@if (isLoading)
{
<div class="flex justify-center py-12">
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
</div>
}
else if (tasks.Count == 0)
{
<div class="bg-white rounded-2xl shadow-lg p-12 text-center">
<div class="w-20 h-20 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<MudIcon Icon="@Icons.Material.Filled.SearchOff" Size="Size.Large" Color="Color.Default" />
</div>
<h3 class="text-lg font-semibold text-slate-700">No tasks match this view</h3>
<p class="text-slate-500 mt-2">Add a task using the form above to create one with this view's criteria</p>
</div>
}
else
{
<div class="space-y-3">
@foreach (var task in tasks)
{
<TaskCard Task="task"
OnToggle="ToggleTask"
OnDelete="DeleteTask"
OnUpdate="OpenUpdateDialog"
OnAddChild="OpenAddChildDialog"
CollapsedTasks="collapsedTasks"
ToggleCollapse="ToggleCollapse" />
}
</div>
}
</div>
</div>
@code {
[Parameter] public string viewId { get; set; } = string.Empty;
private TaskViewDto? _view;
private string _viewName = string.Empty;
private List<TodoItemDto> tasks = new();
private bool isLoading = true;
private CreateTodoItemDto editTask = new();
private string currentTaskId = string.Empty;
private string quickAddTitle = string.Empty;
private HashSet<string> collapsedTasks = new();
protected override async Task OnInitializedAsync()
{
await LoadView();
}
private async Task LoadView()
{
isLoading = true;
try
{
_view = await ViewService.GetViewByIdAsync(viewId);
if (_view != null)
{
_viewName = _view.Name;
tasks = await ViewService.GetTasksByViewAsync(viewId);
}
}
finally
{
isLoading = false;
}
}
private bool HasCriteria(TaskViewDto view)
{
return view.Priority.HasValue ||
view.IsCompleted.HasValue ||
!string.IsNullOrWhiteSpace(view.ContainsText) ||
view.DueDateFrom.HasValue ||
view.DueDateTo.HasValue;
}
private string GetPriorityLabel(int priority)
{
return priority switch
{
2 => "High",
1 => "Medium",
_ => "Low"
};
}
private string GetPriorityChipClass(int priority)
{
return priority switch
{
2 => "bg-red-100 text-red-700",
1 => "bg-yellow-100 text-yellow-700",
_ => "bg-green-100 text-green-700"
};
}
private bool TaskMatchesTextCriteria(string title)
{
if (string.IsNullOrWhiteSpace(_view?.ContainsText))
return true;
return title.Contains(_view.ContainsText, StringComparison.OrdinalIgnoreCase);
}
private async Task HandleQuickAdd(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(quickAddTitle))
{
await QuickAddTask();
}
}
private async Task QuickAddTask()
{
if (string.IsNullOrWhiteSpace(quickAddTitle))
return;
if (!TaskMatchesTextCriteria(quickAddTitle))
{
var confirmed = await Dialog.ShowMessageBox(
"Warning",
$"This task doesn't contain '{_view?.ContainsText}'. Adding it will not show in this view. Continue?",
yesText: "Yes, add it", cancelText: "No");
if (confirmed != true)
return;
}
var newTask = new CreateTodoItemDto
{
Title = quickAddTitle.Trim(),
Priority = 0
};
await ViewService.CreateTodoWithViewCriteriaAsync(viewId, newTask);
quickAddTitle = string.Empty;
await LoadView();
}
private void ToggleCollapse(string taskId)
{
if (collapsedTasks.Contains(taskId))
collapsedTasks.Remove(taskId);
else
collapsedTasks.Add(taskId);
}
private async Task ToggleTask(string id)
{
await TodoService.ToggleCompleteAsync(id);
await LoadView();
}
private async Task DeleteTask(string id)
{
await TodoService.DeleteTodoAsync(id);
await LoadView();
}
private async Task OpenCreateDialog()
{
editTask = new CreateTodoItemDto();
var parameters = new DialogParameters<CreateTaskDialog>
{
{ x => x.Task, editTask },
{ x => x.IsUpdate, false }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTaskDialog>("Create Task", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
if (!TaskMatchesTextCriteria(editTask.Title))
{
var confirmed = await Dialog.ShowMessageBox(
"Warning",
$"This task doesn't contain '{_view?.ContainsText}'. Adding it will not show in this view. Continue?",
yesText: "Yes, add it", cancelText: "No");
if (confirmed != true)
return;
}
await ViewService.CreateTodoWithViewCriteriaAsync(viewId, editTask);
await LoadView();
}
}
private async Task OpenAddChildDialog(TodoItemDto parentTask)
{
editTask = new CreateTodoItemDto { ParentId = parentTask.Id };
var parameters = new DialogParameters<CreateTaskDialog>
{
{ x => x.Task, editTask },
{ x => x.IsUpdate, false },
{ x => x.IsChild, true },
{ x => x.ParentTitle, parentTask.Title }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTaskDialog>("Add Sub-task", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
if (!TaskMatchesTextCriteria(editTask.Title))
{
var confirmed = await Dialog.ShowMessageBox(
"Warning",
$"This task doesn't contain '{_view?.ContainsText}'. Adding it will not show in this view. Continue?",
yesText: "Yes, add it", cancelText: "No");
if (confirmed != true)
return;
}
await ViewService.CreateTodoWithViewCriteriaAsync(viewId, editTask);
await LoadView();
}
}
private async Task OpenUpdateDialog(TodoItemDto task)
{
var confirmed = await Dialog.ShowMessageBox(
"Edit Task Warning",
"Editing this task may remove it from this view if the changes don't match the view criteria. Continue?",
yesText: "Continue", cancelText: "Cancel");
if (confirmed != true)
return;
currentTaskId = task.Id;
editTask = new CreateTodoItemDto
{
Title = task.Title,
Description = task.Description,
Priority = task.Priority,
DueDate = task.DueDate,
ParentId = task.ParentId,
CategoryId = task.CategoryId,
TagIds = task.TagIds.ToList(),
FileId = task.FileId
};
var parameters = new DialogParameters<CreateTaskDialog>
{
{ x => x.Task, editTask },
{ x => x.IsUpdate, true }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await Dialog.ShowAsync<CreateTaskDialog>("Update Task", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
await TodoService.UpdateTodoAsync(new UpdateTodoItemDto
{
Id = currentTaskId,
Title = editTask.Title,
Description = editTask.Description,
Priority = editTask.Priority,
DueDate = editTask.DueDate,
IsCompleted = task.IsCompleted,
ParentId = editTask.ParentId,
CategoryId = editTask.CategoryId,
TagIds = editTask.TagIds,
FileId = editTask.FileId
});
await LoadView();
}
}
}