1 Commits

Author SHA1 Message Date
domrichardson
1b336299ee feat: task system
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
2026-03-27 16:33:11 +00:00
15 changed files with 3876 additions and 17 deletions

View File

@@ -23,6 +23,7 @@ space.<space_permission_key>.<action>
- space_permission_key is derived from the space name (normalized token)
- Example:
- space.product_docs.note.create
- space.product_docs.tasks.create
- space.product_docs.settings.member.manage
## Space-Scoped Actions Currently Enforced
@@ -49,6 +50,16 @@ space.<space_permission_key>.<action>
- note.edit
- note.delete
### Task Management
- tasks.create
- tasks.edit
- tasks.delete
### Task Status Management
- tasks.status.manage
## Wildcard Support
Permissions support wildcard matching with \*.
@@ -59,6 +70,8 @@ Examples:
- Grants all permissions for the product_docs space
- space.\*.note.create
- Grants note.create for all spaces
- space.\*.tasks.\*
- Grants all task permissions for all spaces
- `*`
- Grants all permissions globally

View File

@@ -156,6 +156,15 @@ func main() {
permissionService,
)
taskService := services.NewTaskService(
db.TaskRepo,
db.TaskStatusRepo,
db.NoteRepo,
db.CategoryRepo,
db.MembershipRepo,
permissionService,
)
adminService := services.NewAdminService(
db.UserRepo,
db.GroupRepo,
@@ -189,6 +198,7 @@ func main() {
spaceHandler := handlers.NewSpaceHandler(spaceService)
noteHandler := handlers.NewNoteHandler(noteService)
categoryHandler := handlers.NewCategoryHandler(categoryService)
taskHandler := handlers.NewTaskHandler(taskService)
adminHandler := handlers.NewAdminHandler(adminService)
publicHandler := handlers.NewPublicHandler(spaceService, noteService)
settingsHandler := handlers.NewSettingsHandler(authService)
@@ -258,6 +268,25 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
// Task endpoints
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.GetTask).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.UpdateTask).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.DeleteTask).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/transition", taskHandler.TransitionTaskStatus).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes", taskHandler.LinkTaskNote).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
// Task status endpoints
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.ListStatuses).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.CreateStatus).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/task-statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
// File explorer endpoints (space-scoped)
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.GetFile).Methods("GET")

View File

@@ -452,6 +452,141 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO {
}
}
// ========== TASK DTOs ==========
// CreateTaskRequest represents task creation input.
type CreateTaskRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Description string `json:"description" validate:"max=2000"`
CategoryID *string `json:"category_id,omitempty"`
StatusID string `json:"status_id" validate:"required"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links"`
}
// UpdateTaskRequest represents task update input.
type UpdateTaskRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
StatusID *string `json:"status_id,omitempty"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links,omitempty"`
}
// TaskTransitionRequest allows moving task status by one step.
type TaskTransitionRequest struct {
Direction string `json:"direction" validate:"required,oneof=forward backward"`
}
// LinkTaskNoteRequest links/unlinks a note from a task.
type LinkTaskNoteRequest struct {
NoteID string `json:"note_id" validate:"required"`
}
// TaskDTO represents a task in API responses.
type TaskDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
Title string `json:"title"`
Description string `json:"description"`
CategoryID *string `json:"category_id,omitempty"`
StatusID string `json:"status_id"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
Depth int `json:"depth"`
NoteLinks []string `json:"note_links"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// TaskWithStatusDTO includes status details and child tasks for detail views.
type TaskWithStatusDTO struct {
*TaskDTO
StatusName string `json:"status_name"`
StatusColor string `json:"status_color,omitempty"`
StatusOrder int `json:"status_order"`
Subtasks []*TaskDTO `json:"subtasks"`
}
// CreateTaskStatusRequest represents task status creation input.
type CreateTaskStatusRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Color string `json:"color,omitempty" validate:"max=20"`
}
// UpdateTaskStatusRequest represents task status updates.
type UpdateTaskStatusRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Color string `json:"color,omitempty" validate:"max=20"`
}
// ReorderTaskStatusesRequest represents a full ordered status ID list.
type ReorderTaskStatusesRequest struct {
OrderedStatusIDs []string `json:"ordered_status_ids"`
}
// TaskStatusDTO represents a task status in API responses.
type TaskStatusDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
Name string `json:"name"`
Color string `json:"color,omitempty"`
Order int `json:"order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NewTaskDTO creates a DTO from a task entity.
func NewTaskDTO(task *entities.Task) *TaskDTO {
var categoryID *string
if task.CategoryID != nil {
id := task.CategoryID.Hex()
categoryID = &id
}
var parentTaskID *string
if task.ParentTaskID != nil {
id := task.ParentTaskID.Hex()
parentTaskID = &id
}
noteLinks := make([]string, 0, len(task.NoteLinks))
for _, noteID := range task.NoteLinks {
noteLinks = append(noteLinks, noteID.Hex())
}
return &TaskDTO{
ID: task.ID.Hex(),
SpaceID: task.SpaceID.Hex(),
Title: task.Title,
Description: task.Description,
CategoryID: categoryID,
StatusID: task.StatusID.Hex(),
ParentTaskID: parentTaskID,
Depth: task.Depth,
NoteLinks: noteLinks,
CreatedBy: task.CreatedBy.Hex(),
UpdatedBy: task.UpdatedBy.Hex(),
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: task.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// NewTaskStatusDTO creates a DTO from a task status entity.
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
return &TaskStatusDTO{
ID: status.ID.Hex(),
SpaceID: status.SpaceID.Hex(),
Name: status.Name,
Color: status.Color,
Order: status.Order,
CreatedAt: status.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: status.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ========== ERROR DTOs ==========
// ErrorResponse represents an error response

View File

@@ -0,0 +1,880 @@
package services
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TaskService handles task and task status operations.
type TaskService struct {
taskRepo repositories.TaskRepository
taskStatusRepo repositories.TaskStatusRepository
noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository
membershipRepo repositories.MembershipRepository
permissionService *PermissionService
}
// NewTaskService creates a task service.
func NewTaskService(
taskRepo repositories.TaskRepository,
taskStatusRepo repositories.TaskStatusRepository,
noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository,
membershipRepo repositories.MembershipRepository,
permissionService *PermissionService,
) *TaskService {
return &TaskService{
taskRepo: taskRepo,
taskStatusRepo: taskStatusRepo,
noteRepo: noteRepo,
categoryRepo: categoryRepo,
membershipRepo: membershipRepo,
permissionService: permissionService,
}
}
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return err
}
if len(statuses) > 0 {
return nil
}
defaults := []struct {
name string
color string
}{
{name: "Pending", color: "#7c8596"},
{name: "In Progress", color: "#3b82f6"},
{name: "Done", color: "#22c55e"},
}
for idx, status := range defaults {
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
SpaceID: spaceID,
Name: status.name,
Color: status.color,
Order: idx,
}); err != nil {
return err
}
}
return nil
}
func (s *TaskService) hasTaskPermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
if s.permissionService == nil {
return false, errors.New("permission service unavailable")
}
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
}
func (s *TaskService) requireSpaceAccess(ctx context.Context, userID, spaceID bson.ObjectID) error {
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return errors.New("unauthorized")
}
return nil
}
func toObjectIDPtr(hexID *string) (*bson.ObjectID, error) {
if hexID == nil || strings.TrimSpace(*hexID) == "" {
return nil, nil
}
id, err := bson.ObjectIDFromHex(*hexID)
if err != nil {
return nil, errors.New("invalid object id")
}
return &id, nil
}
func toObjectIDs(hexIDs []string) ([]bson.ObjectID, error) {
result := make([]bson.ObjectID, 0, len(hexIDs))
seen := map[bson.ObjectID]struct{}{}
for _, hexID := range hexIDs {
hexID = strings.TrimSpace(hexID)
if hexID == "" {
continue
}
id, err := bson.ObjectIDFromHex(hexID)
if err != nil {
return nil, errors.New("invalid linked note id")
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result, nil
}
func (s *TaskService) validateCategory(ctx context.Context, spaceID bson.ObjectID, categoryID *bson.ObjectID) error {
if categoryID == nil {
return nil
}
category, err := s.categoryRepo.GetCategoryByID(ctx, *categoryID)
if err != nil || category.SpaceID != spaceID {
return errors.New("invalid category")
}
return nil
}
func (s *TaskService) validateNoteLinks(ctx context.Context, spaceID bson.ObjectID, noteLinks []bson.ObjectID) error {
for _, noteID := range noteLinks {
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil || note.SpaceID != spaceID {
return errors.New("invalid linked note")
}
}
return nil
}
func (s *TaskService) validateStatus(ctx context.Context, spaceID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID {
return nil, errors.New("invalid task status")
}
return status, nil
}
func (s *TaskService) resolveDepthAndParent(ctx context.Context, spaceID bson.ObjectID, parentTaskID *bson.ObjectID) (int, error) {
if parentTaskID == nil {
return 0, nil
}
parent, err := s.taskRepo.GetTaskByID(ctx, *parentTaskID)
if err != nil || parent.SpaceID != spaceID {
return 0, errors.New("invalid parent task")
}
depth := parent.Depth + 1
if depth > entities.MaxTaskDepth {
return 0, fmt.Errorf("max task depth is %d", entities.MaxTaskDepth+1)
}
return depth, nil
}
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, spaceID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
if currentStatusID == requestedStatusID {
return true, nil
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return false, err
}
currentIdx := -1
requestedIdx := -1
for idx, status := range statuses {
if status.ID == currentStatusID {
currentIdx = idx
}
if status.ID == requestedStatusID {
requestedIdx = idx
}
}
if currentIdx == -1 || requestedIdx == -1 {
return false, errors.New("status not found in sequence")
}
delta := requestedIdx - currentIdx
if delta < 0 {
delta = -delta
}
return delta == 1, nil
}
func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskRequest) (*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.create")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
categoryID, err := toObjectIDPtr(req.CategoryID)
if err != nil {
return nil, errors.New("invalid category")
}
if err := s.validateCategory(ctx, spaceID, categoryID); err != nil {
return nil, err
}
parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
if err != nil {
return nil, errors.New("invalid parent task")
}
depth, err := s.resolveDepthAndParent(ctx, spaceID, parentTaskID)
if err != nil {
return nil, err
}
noteLinks, err := toObjectIDs(req.NoteLinks)
if err != nil {
return nil, err
}
if err := s.validateNoteLinks(ctx, spaceID, noteLinks); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
if len(statuses) == 0 {
return nil, errors.New("no task statuses configured")
}
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].Order < statuses[j].Order
})
statusID := statuses[0].ID
requestedStatusID := strings.TrimSpace(req.StatusID)
if requestedStatusID != "" && parentTaskID == nil {
parsedStatusID, parseErr := bson.ObjectIDFromHex(requestedStatusID)
if parseErr != nil {
return nil, errors.New("invalid task status")
}
if _, validateErr := s.validateStatus(ctx, spaceID, parsedStatusID); validateErr != nil {
return nil, validateErr
}
statusID = parsedStatusID
}
task := &entities.Task{
SpaceID: spaceID,
Title: strings.TrimSpace(req.Title),
Description: strings.TrimSpace(req.Description),
CategoryID: categoryID,
StatusID: statusID,
ParentTaskID: parentTaskID,
Depth: depth,
NoteLinks: noteLinks,
CreatedBy: userID,
UpdatedBy: userID,
}
if task.Title == "" {
return nil, errors.New("title is required")
}
if err := s.taskRepo.CreateTask(ctx, task); err != nil {
return nil, err
}
return dto.NewTaskDTO(task), nil
}
func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID bson.ObjectID) (*dto.TaskWithStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return nil, errors.New("task not found")
}
status, err := s.validateStatus(ctx, spaceID, task.StatusID)
if err != nil {
return nil, err
}
subtasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"parent_task_id": task.ID})
if err != nil {
return nil, err
}
subtaskDTOs := make([]*dto.TaskDTO, 0, len(subtasks))
for _, subtask := range subtasks {
subtaskDTOs = append(subtaskDTOs, dto.NewTaskDTO(subtask))
}
return &dto.TaskWithStatusDTO{
TaskDTO: dto.NewTaskDTO(task),
StatusName: status.Name,
StatusColor: status.Color,
StatusOrder: status.Order,
Subtasks: subtaskDTOs,
}, nil
}
func (s *TaskService) ListTasks(
ctx context.Context,
spaceID, userID bson.ObjectID,
categoryID, statusID, parentTaskID *string,
) ([]*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
filters := map[string]any{}
if categoryID != nil && strings.TrimSpace(*categoryID) != "" {
id, err := bson.ObjectIDFromHex(*categoryID)
if err != nil {
return nil, errors.New("invalid category filter")
}
filters["category_id"] = id
}
if statusID != nil && strings.TrimSpace(*statusID) != "" {
id, err := bson.ObjectIDFromHex(*statusID)
if err != nil {
return nil, errors.New("invalid status filter")
}
filters["status_id"] = id
}
if parentTaskID != nil {
parentFilter := strings.TrimSpace(*parentTaskID)
switch parentFilter {
case "":
case "root":
filters["parent_task_id"] = nil
default:
id, err := bson.ObjectIDFromHex(parentFilter)
if err != nil {
return nil, errors.New("invalid parent task filter")
}
filters["parent_task_id"] = id
}
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, filters)
if err != nil {
return nil, err
}
result := make([]*dto.TaskDTO, 0, len(tasks))
for _, task := range tasks {
result = append(result, dto.NewTaskDTO(task))
}
return result, nil
}
func (s *TaskService) SearchTasks(ctx context.Context, spaceID, userID bson.ObjectID, query string) ([]*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
query = strings.TrimSpace(query)
if query == "" {
return []*dto.TaskDTO{}, nil
}
tasks, err := s.taskRepo.SearchTasks(ctx, spaceID, query)
if err != nil {
return nil, err
}
result := make([]*dto.TaskDTO, 0, len(tasks))
for _, task := range tasks {
result = append(result, dto.NewTaskDTO(task))
}
return result, nil
}
func (s *TaskService) ListTasksLinkedToNote(ctx context.Context, spaceID, noteID, userID bson.ObjectID) ([]*dto.TaskWithStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil {
return nil, errors.New("note not found")
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
statusByID := map[bson.ObjectID]*entities.TaskStatus{}
for _, status := range statuses {
statusByID[status.ID] = status
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID})
if err != nil {
return nil, err
}
result := make([]*dto.TaskWithStatusDTO, 0, len(tasks))
for _, task := range tasks {
status := statusByID[task.StatusID]
if status == nil {
continue
}
result = append(result, &dto.TaskWithStatusDTO{
TaskDTO: dto.NewTaskDTO(task),
StatusName: status.Name,
StatusColor: status.Color,
StatusOrder: status.Order,
Subtasks: []*dto.TaskDTO{},
})
}
return result, nil
}
func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bson.ObjectID, req *dto.UpdateTaskRequest) (*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return nil, errors.New("task not found")
}
if req.Title != nil {
task.Title = strings.TrimSpace(*req.Title)
if task.Title == "" {
return nil, errors.New("title is required")
}
}
if req.Description != nil {
task.Description = strings.TrimSpace(*req.Description)
}
if req.CategoryID != nil {
if strings.TrimSpace(*req.CategoryID) == "" {
task.CategoryID = nil
} else {
categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID)
if parseErr != nil {
return nil, errors.New("invalid category")
}
task.CategoryID = &categoryID
}
if err := s.validateCategory(ctx, spaceID, task.CategoryID); err != nil {
return nil, err
}
}
if req.ParentTaskID != nil {
if strings.TrimSpace(*req.ParentTaskID) == "" {
task.ParentTaskID = nil
task.Depth = 0
} else {
parentID, parseErr := bson.ObjectIDFromHex(*req.ParentTaskID)
if parseErr != nil {
return nil, errors.New("invalid parent task")
}
if parentID == task.ID {
return nil, errors.New("task cannot be its own parent")
}
depth, depthErr := s.resolveDepthAndParent(ctx, spaceID, &parentID)
if depthErr != nil {
return nil, depthErr
}
task.ParentTaskID = &parentID
task.Depth = depth
}
}
if req.StatusID != nil {
statusID, parseErr := bson.ObjectIDFromHex(*req.StatusID)
if parseErr != nil {
return nil, errors.New("invalid status")
}
if _, err := s.validateStatus(ctx, spaceID, statusID); err != nil {
return nil, err
}
adjacent, err := s.isAdjacentStatusMove(ctx, spaceID, task.StatusID, statusID)
if err != nil {
return nil, err
}
if !adjacent {
return nil, errors.New("status transition must follow adjacent order sequence")
}
task.StatusID = statusID
}
if req.NoteLinks != nil {
noteLinks, convertErr := toObjectIDs(req.NoteLinks)
if convertErr != nil {
return nil, convertErr
}
if err := s.validateNoteLinks(ctx, spaceID, noteLinks); err != nil {
return nil, err
}
task.NoteLinks = noteLinks
}
task.UpdatedBy = userID
task.UpdatedAt = time.Now()
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
return nil, err
}
return dto.NewTaskDTO(task), nil
}
func (s *TaskService) DeleteTask(ctx context.Context, spaceID, taskID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.delete")
if err != nil {
return err
}
if !hasPermission {
return errors.New("insufficient permissions")
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return errors.New("task not found")
}
if childCount, err := s.taskRepo.CountChildren(ctx, task.ID); err == nil && childCount > 0 {
return errors.New("cannot delete task with subtasks")
}
return s.taskRepo.DeleteTask(ctx, taskID)
}
func (s *TaskService) TransitionTaskStatus(ctx context.Context, spaceID, taskID, userID bson.ObjectID, direction string) (*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return nil, errors.New("task not found")
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
current := -1
for idx, status := range statuses {
if status.ID == task.StatusID {
current = idx
break
}
}
if current == -1 {
return nil, errors.New("task has invalid status")
}
switch strings.ToLower(strings.TrimSpace(direction)) {
case "forward":
if current+1 >= len(statuses) {
return nil, errors.New("task is already at final status")
}
task.StatusID = statuses[current+1].ID
case "backward":
if current-1 < 0 {
return nil, errors.New("task is already at initial status")
}
task.StatusID = statuses[current-1].ID
default:
return nil, errors.New("invalid transition direction")
}
task.UpdatedBy = userID
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
return nil, err
}
return dto.NewTaskDTO(task), nil
}
func (s *TaskService) LinkNoteToTask(ctx context.Context, spaceID, taskID, noteID, userID bson.ObjectID) (*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return nil, errors.New("task not found")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil || note.SpaceID != spaceID {
return nil, errors.New("note not found")
}
for _, linkedNoteID := range task.NoteLinks {
if linkedNoteID == noteID {
return dto.NewTaskDTO(task), nil
}
}
task.NoteLinks = append(task.NoteLinks, noteID)
task.UpdatedBy = userID
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
return nil, err
}
return dto.NewTaskDTO(task), nil
}
func (s *TaskService) UnlinkNoteFromTask(ctx context.Context, spaceID, taskID, noteID, userID bson.ObjectID) (*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return nil, errors.New("task not found")
}
filtered := make([]bson.ObjectID, 0, len(task.NoteLinks))
for _, linkedNoteID := range task.NoteLinks {
if linkedNoteID != noteID {
filtered = append(filtered, linkedNoteID)
}
}
task.NoteLinks = filtered
task.UpdatedBy = userID
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
return nil, err
}
return dto.NewTaskDTO(task), nil
}
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
result := make([]*dto.TaskStatusDTO, 0, len(statuses))
for _, status := range statuses {
result = append(result, dto.NewTaskStatusDTO(status))
}
return result, nil
}
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
status := &entities.TaskStatus{
SpaceID: spaceID,
Name: strings.TrimSpace(req.Name),
Color: strings.TrimSpace(req.Color),
Order: len(statuses),
}
if status.Name == "" {
return nil, errors.New("status name is required")
}
if err := s.taskStatusRepo.CreateStatus(ctx, status); err != nil {
return nil, err
}
return dto.NewTaskStatusDTO(status), nil
}
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID {
return nil, errors.New("task status not found")
}
status.Name = strings.TrimSpace(req.Name)
status.Color = strings.TrimSpace(req.Color)
if status.Name == "" {
return nil, errors.New("status name is required")
}
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
return nil, err
}
return dto.NewTaskStatusDTO(status), nil
}
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
if err != nil {
return err
}
if !hasPermission {
return errors.New("insufficient permissions")
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return err
}
if len(statuses) <= 1 {
return errors.New("at least one status is required")
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"status_id": statusID})
if err != nil {
return err
}
if len(tasks) > 0 {
return errors.New("cannot delete status used by tasks")
}
if err := s.taskStatusRepo.DeleteStatus(ctx, statusID); err != nil {
return err
}
return s.normalizeStatusOrder(ctx, spaceID)
}
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
if len(statuses) != len(orderedStatusIDs) {
return nil, errors.New("ordered status list must include every status exactly once")
}
statusByID := make(map[bson.ObjectID]*entities.TaskStatus, len(statuses))
for _, status := range statuses {
statusByID[status.ID] = status
}
seen := map[bson.ObjectID]struct{}{}
orderedStatuses := make([]*entities.TaskStatus, 0, len(orderedStatusIDs))
for _, statusIDHex := range orderedStatusIDs {
statusID, parseErr := bson.ObjectIDFromHex(statusIDHex)
if parseErr != nil {
return nil, errors.New("invalid status id in ordered_status_ids")
}
status := statusByID[statusID]
if status == nil {
return nil, errors.New("status id does not belong to this space")
}
if _, exists := seen[statusID]; exists {
return nil, errors.New("duplicate status id in ordered_status_ids")
}
seen[statusID] = struct{}{}
orderedStatuses = append(orderedStatuses, status)
}
minOrder := statuses[0].Order
for _, status := range statuses {
if status.Order < minOrder {
minOrder = status.Order
}
}
tempBase := minOrder - len(statuses) - 1
for idx, status := range orderedStatuses {
status.Order = tempBase + idx
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
return nil, err
}
}
for idx, status := range orderedStatuses {
status.Order = idx
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
return nil, err
}
}
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
result := make([]*dto.TaskStatusDTO, 0, len(updatedStatuses))
for _, status := range updatedStatuses {
result = append(result, dto.NewTaskStatusDTO(status))
}
return result, nil
}
func (s *TaskService) normalizeStatusOrder(ctx context.Context, spaceID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return err
}
sort.SliceStable(statuses, func(i, j int) bool {
return statuses[i].Order < statuses[j].Order
})
for idx, status := range statuses {
if status.Order == idx {
continue
}
status.Order = idx
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,37 @@
package entities
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
const MaxTaskDepth = 2
// Task represents a task and supports up to 3 nesting levels (0,1,2).
type Task struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
Title string `bson:"title"`
Description string `bson:"description"`
CategoryID *bson.ObjectID `bson:"category_id,omitempty"`
StatusID bson.ObjectID `bson:"status_id"`
ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"`
Depth int `bson:"depth"`
NoteLinks []bson.ObjectID `bson:"note_links"`
CreatedBy bson.ObjectID `bson:"created_by"`
UpdatedBy bson.ObjectID `bson:"updated_by"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
// TaskStatus defines the ordered linear status progression for a space.
type TaskStatus struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
Name string `bson:"name"`
Color string `bson:"color,omitempty"`
Order int `bson:"order"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}

View File

@@ -216,3 +216,24 @@ type NoteRevisionRepository interface {
// GetRevisionByID retrieves a specific revision
GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error)
}
// TaskRepository defines task operations
type TaskRepository interface {
CreateTask(ctx context.Context, task *entities.Task) error
GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error)
ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error)
SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error)
UpdateTask(ctx context.Context, task *entities.Task) error
DeleteTask(ctx context.Context, id bson.ObjectID) error
DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
}
// TaskStatusRepository defines task status operations
type TaskStatusRepository interface {
CreateStatus(ctx context.Context, status *entities.TaskStatus) error
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error)
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
DeleteStatus(ctx context.Context, id bson.ObjectID) error
}

View File

@@ -16,6 +16,8 @@ type Database struct {
MembershipRepo *MembershipRepository
NoteRepo *NoteRepository
CategoryRepo *CategoryRepository
TaskRepo *TaskRepository
TaskStatusRepo *TaskStatusRepository
RevisionRepo *NoteRevisionRepository
GroupRepo *PermissionGroupRepository
ProviderRepo *AuthProviderRepository
@@ -47,6 +49,8 @@ func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) {
MembershipRepo: NewMembershipRepository(db),
NoteRepo: NewNoteRepository(db),
CategoryRepo: NewCategoryRepository(db),
TaskRepo: NewTaskRepository(db),
TaskStatusRepo: NewTaskStatusRepository(db),
RevisionRepo: NewNoteRevisionRepository(db),
GroupRepo: NewPermissionGroupRepository(db),
ProviderRepo: NewAuthProviderRepository(db),
@@ -80,6 +84,12 @@ func (d *Database) EnsureIndexes(ctx context.Context) error {
if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.TaskRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.GroupRepo.EnsureIndexes(ctx); err != nil {
return err
}

View File

@@ -0,0 +1,185 @@
package database
import (
"context"
"errors"
"time"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// TaskRepository implements task data access.
type TaskRepository struct {
collection *mongo.Collection
}
// NewTaskRepository creates a new task repository.
func NewTaskRepository(db *mongo.Database) *TaskRepository {
return &TaskRepository{collection: db.Collection("tasks")}
}
func (r *TaskRepository) CreateTask(ctx context.Context, task *entities.Task) error {
task.ID = bson.NewObjectID()
task.CreatedAt = time.Now()
task.UpdatedAt = time.Now()
if task.NoteLinks == nil {
task.NoteLinks = []bson.ObjectID{}
}
_, err := r.collection.InsertOne(ctx, task)
return err
}
func (r *TaskRepository) GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error) {
var task entities.Task
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&task)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("task not found")
}
return nil, err
}
return &task, nil
}
func (r *TaskRepository) ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error) {
query := bson.M{"space_id": spaceID}
for k, v := range filters {
query[k] = v
}
opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})
cursor, err := r.collection.Find(ctx, query, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var tasks []*entities.Task
if err := cursor.All(ctx, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
func (r *TaskRepository) SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error) {
cursor, err := r.collection.Find(ctx, bson.M{
"space_id": spaceID,
"$or": []bson.M{
{"title": bson.M{"$regex": query, "$options": "i"}},
{"description": bson.M{"$regex": query, "$options": "i"}},
},
}, options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetLimit(30))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var tasks []*entities.Task
if err := cursor.All(ctx, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
func (r *TaskRepository) UpdateTask(ctx context.Context, task *entities.Task) error {
task.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": task.ID}, task)
return err
}
func (r *TaskRepository) DeleteTask(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err
}
func (r *TaskRepository) CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) {
return r.collection.CountDocuments(ctx, bson.M{"parent_task_id": parentTaskID})
}
func (r *TaskRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "updated_at", Value: -1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "status_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "parent_task_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}},
})
return err
}
// TaskStatusRepository implements task status data access.
type TaskStatusRepository struct {
collection *mongo.Collection
}
// NewTaskStatusRepository creates a new task status repository.
func NewTaskStatusRepository(db *mongo.Database) *TaskStatusRepository {
return &TaskStatusRepository{collection: db.Collection("task_statuses")}
}
func (r *TaskStatusRepository) CreateStatus(ctx context.Context, status *entities.TaskStatus) error {
status.ID = bson.NewObjectID()
status.CreatedAt = time.Now()
status.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, status)
return err
}
func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error) {
var status entities.TaskStatus
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&status)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("task status not found")
}
return nil, err
}
return &status, nil
}
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var statuses []*entities.TaskStatus
if err := cursor.All(ctx, &statuses); err != nil {
return nil, err
}
return statuses, nil
}
func (r *TaskStatusRepository) UpdateStatus(ctx context.Context, status *entities.TaskStatus) error {
status.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": status.ID}, status)
return err
}
func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "order", Value: 1}},
Options: options.Index().SetUnique(true),
},
})
return err
}

View File

@@ -0,0 +1,398 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TaskHandler handles task and task status endpoints.
type TaskHandler struct {
taskService *services.TaskService
}
// NewTaskHandler creates a task handler.
func NewTaskHandler(taskService *services.TaskService) *TaskHandler {
return &TaskHandler{taskService: taskService}
}
func parseIDsFromRequest(r *http.Request) (bson.ObjectID, bson.ObjectID, error) {
userID, err := getUserObjectID(r)
if err != nil {
return bson.NilObjectID, bson.NilObjectID, err
}
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
return bson.NilObjectID, bson.NilObjectID, err
}
return userID, spaceID, nil
}
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
var req dto.CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
task, err := h.taskService.CreateTask(r.Context(), spaceID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
categoryID := strings.TrimSpace(r.URL.Query().Get("categoryId"))
statusID := strings.TrimSpace(r.URL.Query().Get("statusId"))
parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId"))
categoryFilter := &categoryID
statusFilter := &statusID
parentFilter := &parentTaskID
if categoryID == "" {
categoryFilter = nil
}
if statusID == "" {
statusFilter = nil
}
if parentTaskID == "" {
parentFilter = nil
}
tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, categoryFilter, statusFilter, parentFilter)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
query := r.URL.Query().Get("q")
tasks, err := h.taskService.SearchTasks(r.Context(), spaceID, userID, query)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
task, err := h.taskService.GetTaskByID(r.Context(), spaceID, taskID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
var req dto.UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
task, err := h.taskService.UpdateTask(r.Context(), spaceID, taskID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
if err := h.taskService.DeleteTask(r.Context(), spaceID, taskID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) TransitionTaskStatus(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
var req dto.TaskTransitionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
task, err := h.taskService.TransitionTaskStatus(r.Context(), spaceID, taskID, userID, req.Direction)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) LinkTaskNote(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
var req dto.LinkTaskNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(req.NoteID)
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
task, err := h.taskService.LinkNoteToTask(r.Context(), spaceID, taskID, noteID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) UnlinkTaskNote(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"])
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
task, err := h.taskService.UnlinkNoteFromTask(r.Context(), spaceID, taskID, noteID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) ListTasksByNote(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"])
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
tasks, err := h.taskService.ListTasksLinkedToNote(r.Context(), spaceID, noteID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(statuses)
}
func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
var req dto.CreateTaskStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
status, err := h.taskService.CreateStatus(r.Context(), spaceID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(status)
}
func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest)
return
}
var req dto.UpdateTaskStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, statusID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest)
return
}
if err := h.taskService.DeleteStatus(r.Context(), spaceID, statusID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
var req dto.ReorderTaskStatusesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, userID, req.OrderedStatusIDs)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(statuses)
}

View File

@@ -128,7 +128,11 @@
</h5>
</div>
<div class="col-auto d-flex align-items-center">
<div v-if="!selectedNote || isSearchRoute" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
<div class="btn-group me-2" role="group" aria-label="Workspace mode">
<button type="button" class="btn action-button" :class="activeView === 'notes' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'notes'">Notes</button>
<button type="button" class="btn action-button" :class="activeView === 'tasks' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'tasks'">Tasks</button>
</div>
<div v-if="activeView === 'notes' && (!selectedNote || isSearchRoute)" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
<button
type="button"
class="btn action-button"
@@ -151,7 +155,7 @@
</button>
</div>
<button
v-if="canEditNotes && selectedNote && !isEditingNote && !isSearchRoute"
v-if="activeView === 'notes' && canEditNotes && selectedNote && !isEditingNote && !isSearchRoute"
class="btn btn-outline-secondary me-2 action-button"
aria-label="Edit note"
title="Edit note"
@@ -161,7 +165,7 @@
<span class="action-label">Edit Note</span>
</button>
<button
v-if="canShareSelectedNote && !isEditingNote && !isSearchRoute"
v-if="activeView === 'notes' && canShareSelectedNote && !isEditingNote && !isSearchRoute"
class="btn btn-outline-primary me-2 action-button"
:aria-label="shareCopied ? 'Link copied' : 'Share note'"
:title="shareCopied ? 'Link copied' : 'Share note'"
@@ -170,18 +174,36 @@
<i class="mdi mdi-share-variant-outline me-1" aria-hidden="true"></i>
<span class="action-label">{{ shareCopied ? "Copied" : "Share" }}</span>
</button>
<button v-if="canCreateNotes" class="btn btn-primary action-button" aria-label="New note" title="New note" @click="showCreateNoteModal = true">
<button v-if="activeView === 'notes' && canCreateNotes" class="btn btn-primary action-button" aria-label="New note" title="New note" @click="showCreateNoteModal = true">
<i class="mdi mdi-note-plus-outline me-1" aria-hidden="true"></i>
<span class="action-label">New Note</span>
</button>
<button v-if="activeView === 'tasks' && canCreateTasks" class="btn btn-primary action-button" aria-label="New task" title="New task" @click="openTaskCreateModal">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
<span class="action-label">New Task</span>
</button>
</div>
</div>
</div>
<!-- Note Editor or Note List -->
<div class="content p-4">
<TaskBoard
v-if="activeView === 'tasks'"
:tasks="tasks"
:statuses="taskStatuses"
:category-options="categoryOptions"
@create-task="openTaskCreateModal"
@select-task="openTaskDetail"
@filter-change="applyTaskFilters"
@reorder-status="reorderTaskStatuses"
@create-status="createTaskStatus"
@rename-status="renameTaskStatus"
@delete-status="deleteTaskStatus"
@update-task-status="updateTaskStatusFromBoard"
/>
<SearchResultsPage
v-if="isSearchRoute"
v-else-if="isSearchRoute"
:notes="searchResults"
:query="searchQuery"
:current-page="searchPage"
@@ -199,8 +221,16 @@
@save="updateNote"
@delete="deleteNote"
@cancel="cancelEditingNote"
@open-linked-task="openLinkedTaskFromNote"
/>
<NoteViewer
v-else-if="selectedNote"
:note="selectedNote"
:category-options="categoryOptions"
:space-id="currentSpace?.id"
:linked-tasks="linkedTasksForSelectedNote"
@open-linked-task="openLinkedTaskFromNote"
/>
<NoteViewer v-else-if="selectedNote" :note="selectedNote" :category-options="categoryOptions" :space-id="currentSpace?.id" />
<NoteList
v-else
:notes="displayedNotes"
@@ -256,6 +286,20 @@
@saved="applyUpdatedSpace"
@deleted="handleSpaceDeleted"
/>
<TaskDetailModal
v-if="showTaskModal"
:task="taskModalDraft || {}"
:statuses="taskStatuses"
:category-options="categoryOptions"
:parent-task-options="taskParentOptions"
:subtasks="taskDetail?.subtasks || []"
@close="showTaskModal = false"
@save-task="saveTask"
@delete-task="removeTask"
@transition="transitionTaskStatus"
@create-subtask="createSubtask"
@open-task="openTaskDetail"
/>
<teleport to="body">
<div v-if="showUnlockModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeUnlockModal">
@@ -304,6 +348,8 @@ import CreateSpaceModal from "./components/CreateSpaceModal.vue";
import CreateCategoryModal from "./components/CreateCategoryModal.vue";
import CreateNoteModal from "./components/CreateNoteModal.vue";
import SpaceSettingsModal from "./components/SpaceSettingsModal.vue";
import TaskBoard from "./components/TaskBoard.vue";
import TaskDetailModal from "./components/TaskDetailModal.vue";
import { sortNotesByPriority } from "./utils/noteSort";
import apiClient from "./services/apiClient";
@@ -345,6 +391,16 @@ const unlockTargetNote = ref(null);
const unlockPassword = ref("");
const unlockError = ref("");
const unlockingNote = ref(false);
const activeView = ref("notes");
const taskFilters = ref({
categoryId: null,
statusId: null,
parentTaskId: null,
});
const showTaskModal = ref(false);
const taskDetail = ref(null);
const taskModalDraft = ref(null);
const linkedTasksForSelectedNote = ref([]);
const currentUser = computed(() => authStore.user);
const isAdminRoute = computed(() => route.path === "/admin");
@@ -370,6 +426,9 @@ const canDeleteCategories = computed(() => authStore.hasSpacePermission(currentS
const canCreateNotes = computed(() => authStore.hasSpacePermission(currentSpace.value, "note.create"));
const canEditNotes = computed(() => authStore.hasSpacePermission(currentSpace.value, "note.edit"));
const canDeleteNotes = computed(() => authStore.hasSpacePermission(currentSpace.value, "note.delete"));
const canCreateTasks = computed(() => authStore.hasSpacePermission(currentSpace.value, "tasks.create"));
const canEditTasks = computed(() => authStore.hasSpacePermission(currentSpace.value, "tasks.edit"));
const canDeleteTasks = computed(() => authStore.hasSpacePermission(currentSpace.value, "tasks.delete"));
const canShareSelectedNote = computed(() => !!selectedNote.value?.is_public && !!currentSpace.value?.id && !!selectedNote.value?.id);
const offcanvasOffsetStyle = computed(() => ({
top: `${navbarHeight.value}px`,
@@ -416,6 +475,15 @@ const displayedNotes = computed(() => {
}
return sortNotesByPriority(collectNotesFromCategory(selectedCategory.value, []));
});
const tasks = computed(() => spaceStore.tasks || []);
const taskStatuses = computed(() => spaceStore.taskStatuses || []);
const initialTaskStatusId = computed(() => {
if (!taskStatuses.value.length) {
return null;
}
return [...taskStatuses.value].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[0]?.id || null;
});
const taskParentOptions = computed(() => tasks.value.filter((task) => task.depth < 2));
const canLoadMoreMainNotes = computed(() => {
if (selectedCategory.value || selectedNote.value) {
@@ -611,9 +679,20 @@ watch(
watch(
() => selectedNote.value?.id,
() => {
async (noteId) => {
shareCopied.value = false;
clearTimeout(shareCopyTimeout.value);
if (!noteId || !currentSpace.value?.id) {
linkedTasksForSelectedNote.value = [];
return;
}
try {
linkedTasksForSelectedNote.value = await spaceStore.fetchTasksForNote(currentSpace.value.id, noteId);
} catch {
linkedTasksForSelectedNote.value = [];
}
},
);
@@ -659,6 +738,8 @@ const selectSpace = async (space) => {
selectedNote.value = null;
selectedCategory.value = null;
isEditingNote.value = false;
linkedTasksForSelectedNote.value = [];
await applyTaskFilters(taskFilters.value);
};
const copyShareLink = async () => {
@@ -821,6 +902,210 @@ const loadMoreMainNotes = async () => {
await spaceStore.loadMoreNotes(currentSpace.value.id);
};
const applyTaskFilters = async (filters) => {
taskFilters.value = filters;
if (!currentSpace.value?.id) {
return;
}
await spaceStore.fetchTasks(currentSpace.value.id, filters);
};
const openTaskCreateModal = () => {
if (!canCreateTasks.value) {
return;
}
taskDetail.value = null;
taskModalDraft.value = {
title: "",
description: "",
category_id: selectedCategory.value?.id || null,
status_id: initialTaskStatusId.value,
parent_task_id: null,
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
depth: 0,
};
activeView.value = "tasks";
showTaskModal.value = true;
};
const openTaskDetail = async (task) => {
if (!currentSpace.value?.id || !task?.id) {
return;
}
try {
taskDetail.value = await spaceStore.getTask(currentSpace.value.id, task.id);
taskModalDraft.value = taskDetail.value;
showTaskModal.value = true;
} catch {
alert("Unable to open task details.");
}
};
const saveTask = async (payload) => {
if (!currentSpace.value?.id) {
return;
}
try {
if (payload.id) {
if (!canEditTasks.value) {
return;
}
const updated = await spaceStore.updateTask(currentSpace.value.id, payload.id, payload);
await openTaskDetail(updated);
} else {
if (!canCreateTasks.value) {
return;
}
const created = await spaceStore.createTask(currentSpace.value.id, payload);
await openTaskDetail(created);
}
if (selectedNote.value?.id) {
linkedTasksForSelectedNote.value = await spaceStore.fetchTasksForNote(currentSpace.value.id, selectedNote.value.id);
}
} catch (error) {
alert(error?.response?.data || "Failed to save task.");
}
};
const removeTask = async (task) => {
if (!currentSpace.value?.id || !task?.id || !canDeleteTasks.value) {
return;
}
if (!confirm(`Delete task "${task.title}"?`)) {
return;
}
try {
await spaceStore.deleteTask(currentSpace.value.id, task.id);
showTaskModal.value = false;
taskDetail.value = null;
if (selectedNote.value?.id) {
linkedTasksForSelectedNote.value = await spaceStore.fetchTasksForNote(currentSpace.value.id, selectedNote.value.id);
}
} catch (error) {
alert(error?.response?.data || "Unable to delete task.");
}
};
const transitionTaskStatus = async ({ taskId, direction }) => {
if (!currentSpace.value?.id || !taskId) {
return;
}
try {
const updated = await spaceStore.transitionTask(currentSpace.value.id, taskId, direction);
await openTaskDetail(updated);
if (selectedNote.value?.id) {
linkedTasksForSelectedNote.value = await spaceStore.fetchTasksForNote(currentSpace.value.id, selectedNote.value.id);
}
} catch (error) {
alert(error?.response?.data || "Unable to change task status.");
}
};
const createSubtask = (parentTask) => {
if (!parentTask || (parentTask.depth ?? 0) >= 2) {
alert("Maximum sub-task depth reached.");
return;
}
taskDetail.value = null;
taskModalDraft.value = {
title: "",
description: "",
category_id: parentTask.category_id || null,
status_id: initialTaskStatusId.value,
parent_task_id: parentTask.id,
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
depth: Math.min((parentTask.depth ?? 0) + 1, 2),
};
showTaskModal.value = true;
};
const createTaskStatus = async (payload) => {
if (!currentSpace.value?.id) {
return;
}
try {
await spaceStore.createTaskStatus(currentSpace.value.id, payload);
} catch (error) {
alert(error?.response?.data || "Unable to create status.");
}
};
const renameTaskStatus = async (status) => {
if (!currentSpace.value?.id || !status?.id) {
return;
}
try {
await spaceStore.updateTaskStatus(currentSpace.value.id, status.id, {
name: status.name,
color: status.color,
});
} catch (error) {
alert(error?.response?.data || "Unable to update status.");
}
};
const deleteTaskStatus = async (status) => {
if (!currentSpace.value?.id || !status?.id) {
return;
}
if (!confirm(`Delete status "${status.name}"?`)) {
return;
}
try {
await spaceStore.deleteTaskStatus(currentSpace.value.id, status.id);
} catch (error) {
alert(error?.response?.data || "Unable to delete status.");
}
};
const reorderTaskStatuses = async (orderedIds) => {
if (!currentSpace.value?.id) {
return;
}
try {
await spaceStore.reorderTaskStatuses(currentSpace.value.id, orderedIds);
} catch (error) {
alert(error?.response?.data || "Unable to reorder statuses.");
}
};
const updateTaskStatusFromBoard = async ({ taskId, currentStatusId, targetStatusId }) => {
if (!currentSpace.value?.id || !taskId || !targetStatusId || currentStatusId === targetStatusId) {
return;
}
const orderedStatuses = [...taskStatuses.value].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const currentIndex = orderedStatuses.findIndex((item) => item.id === currentStatusId);
const targetIndex = orderedStatuses.findIndex((item) => item.id === targetStatusId);
try {
let updatedTask = null;
if (currentIndex >= 0 && targetIndex >= 0) {
const direction = targetIndex > currentIndex ? "forward" : "backward";
const steps = Math.abs(targetIndex - currentIndex);
for (let i = 0; i < steps; i++) {
updatedTask = await spaceStore.transitionTask(currentSpace.value.id, taskId, direction);
}
} else {
updatedTask = await spaceStore.updateTask(currentSpace.value.id, taskId, { status_id: targetStatusId });
}
if (updatedTask && taskDetail.value?.id === updatedTask.id) {
await openTaskDetail(updatedTask);
}
if (selectedNote.value?.id) {
linkedTasksForSelectedNote.value = await spaceStore.fetchTasksForNote(currentSpace.value.id, selectedNote.value.id);
}
} catch (error) {
alert(error?.response?.data || "Unable to update task status.");
}
};
const openLinkedTaskFromNote = async (task) => {
activeView.value = "tasks";
await openTaskDetail(task);
};
const createSpace = async (spaceData) => {
showCreateSpaceModal.value = false;
await spaceStore.createSpace(spaceData);

View File

@@ -13,6 +13,16 @@
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
Files
</button>
<button
v-if="spaceId"
class="btn btn-sm"
:class="showTaskPicker ? 'btn-secondary' : 'btn-outline-secondary'"
:title="showTaskPicker ? 'Hide task picker' : 'Browse & insert task mentions'"
@click="toggleTaskPicker"
>
<i class="mdi mdi-checkbox-marked-circle-outline me-1" aria-hidden="true"></i>
Tasks
</button>
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
</div>
@@ -25,19 +35,52 @@
</div>
<div class="row">
<div :class="showFileExplorer ? 'col-12 col-md-5' : 'col-12 col-md-6'">
<div :class="editorColumnClass">
<textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
<div v-if="showTaskMention" class="task-mention-panel">
<div class="small text-muted mb-1">Link task for "{{ taskMentionQuery }}"</div>
<button v-for="task in taskMentionResults" :key="task.id" class="task-mention-option" @click="selectMentionTask(task)">
<span>{{ task.title }}</span>
<small>Depth {{ task.depth + 1 }}</small>
</button>
</div>
</div>
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
<div class="preview-pane border rounded p-3">
<div :class="previewColumnClass">
<div class="preview-pane border rounded p-3" @click="onPreviewClick">
<div class="markdown-body" v-html="renderedMarkdown"></div>
</div>
</div>
<div v-if="showFileExplorer" class="col-12 col-md-3 mt-3 mt-md-0">
<div v-if="showFileExplorer" :class="fileExplorerColumnClass">
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
</div>
<div v-if="showTaskPicker" :class="taskPickerColumnClass">
<div class="task-picker border rounded">
<div class="task-picker-header px-2 py-1 border-bottom d-flex align-items-center gap-2">
<i class="mdi mdi-checkbox-marked-circle-outline text-muted" aria-hidden="true"></i>
<span class="small fw-semibold">Space Tasks</span>
<button class="btn btn-link btn-sm p-0 text-muted ms-auto" title="Refresh" @click="refreshTaskPicker">
<i class="mdi mdi-refresh" aria-hidden="true"></i>
</button>
</div>
<div class="task-picker-search p-2 border-bottom">
<input v-model="taskPickerQuery" type="text" class="form-control form-control-sm" placeholder="Search tasks by title..." />
</div>
<div v-if="taskPickerLoading" class="task-picker-empty text-muted small">
<i class="mdi mdi-loading mdi-spin me-1" aria-hidden="true"></i>
Loading tasks...
</div>
<div v-else-if="!taskPickerItems.length" class="task-picker-empty text-muted small">No tasks found.</div>
<div v-else class="task-picker-list">
<button v-for="task in taskPickerItems" :key="task.id" class="task-picker-item" @click="insertTaskMention(task)">
<span class="task-picker-title">{{ task.title }}</span>
<small>{{ task.picker_status_name }}</small>
</button>
</div>
</div>
</div>
</div>
<div class="mt-3">
@@ -98,6 +141,7 @@
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore";
import { useSpaceStore } from "../stores/spaceStore";
import { renderMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue";
@@ -120,8 +164,9 @@ const props = defineProps({
},
});
const emit = defineEmits(["save", "delete", "cancel"]);
const emit = defineEmits(["save", "delete", "cancel", "open-linked-task"]);
const settingsStore = useSettingsStore();
const spaceStore = useSpaceStore();
const publicSharingEnabled = ref(true);
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
@@ -135,12 +180,110 @@ const notePassword = ref("");
const saveTimeout = ref(null);
const saveState = ref("saved");
const saveStateTimeout = ref(null);
const taskMentionQuery = ref("");
const taskMentionResults = ref([]);
const showTaskMention = ref(false);
const linkedTasks = ref([]);
const showTaskPicker = ref(false);
const taskPickerQuery = ref("");
const taskPickerLoading = ref(false);
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
const editorColumnClass = computed(() => {
if (hasTwoAuxPanels.value) {
return "col-12 col-xl-4";
}
return hasAuxPanels.value ? "col-12 col-md-5" : "col-12 col-md-6";
});
const previewColumnClass = computed(() => {
if (hasTwoAuxPanels.value) {
return "col-12 col-xl-4 mt-3 mt-xl-0";
}
return hasAuxPanels.value ? "col-12 col-md-4 mt-3 mt-md-0" : "col-12 col-md-6 mt-3 mt-md-0";
});
const fileExplorerColumnClass = computed(() => {
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
});
const taskPickerColumnClass = computed(() => {
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
});
const taskStatusNameById = computed(() => {
const map = new Map();
for (const status of spaceStore.taskStatuses || []) {
if (status?.id) {
map.set(status.id, status.name || "");
}
}
return map;
});
const taskPickerItems = computed(() => {
const query = taskPickerQuery.value.trim().toLowerCase();
const allTasks = [...(spaceStore.tasks || [])]
.map((task) => ({
...task,
picker_status_name: task.status_name || task.status?.name || taskStatusNameById.value.get(task.status_id) || "Unknown",
}))
.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
if (!query) {
return allTasks;
}
return allTasks.filter((task) => (task.title || "").toLowerCase().includes(query));
});
const renderedMarkdown = computed(() => {
const html = renderMarkdown(editingNote.value.content || "");
const html = renderMarkdown(enrichTaskMentions(editingNote.value.content || ""));
return DOMPurify.sanitize(html);
});
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
const taskByTitle = computed(() => {
const map = new Map();
for (const task of linkedTasks.value || []) {
const key = normalizeTaskTitle(task.title);
if (!key || map.has(key)) {
continue;
}
map.set(key, task);
}
return map;
});
const enrichTaskMentions = (content) => {
if (!content) {
return "";
}
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
const title = (rawTitle || "").trim();
if (!title) {
return full;
}
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
if (!linkedTask?.id) {
return full;
}
const statusName = (linkedTask.status_name || "Unknown").trim();
const safeTitle = title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const safeStatusName = statusName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const statusColor = (linkedTask.status_color || "").trim();
const safeStatusColor = statusColor.replace(/"/g, "").replace(/</g, "").replace(/>/g, "");
const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : "";
return `<a href="#task:${linkedTask.id}" class="task-inline-link"${styleAttr}><i class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></i><span class="task-inline-title">${safeTitle}</span><span class="task-inline-status">${safeStatusName}</span></a>`;
});
};
const saveStatusLabel = computed(() => {
switch (saveState.value) {
case "dirty":
@@ -155,12 +298,21 @@ const saveStatusLabel = computed(() => {
watch(
() => props.note,
(newNote) => {
async (newNote) => {
editingNote.value = { ...newNote };
tagsInput.value = newNote.tags?.join(", ") || "";
passwordAction.value = "keep";
notePassword.value = "";
saveState.value = "saved";
if (props.spaceId && newNote?.id) {
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, newNote.id);
} catch {
linkedTasks.value = [];
}
} else {
linkedTasks.value = [];
}
},
);
@@ -211,12 +363,15 @@ const saveNote = () => {
notePassword.value = "";
}
markSavedSoon();
// Auto-link any @task(Title) mentions present in the saved content
syncTaskMentionLinks(note.content || "");
};
const autoSave = () => {
saveState.value = "dirty";
clearTimeout(saveTimeout.value);
saveTimeout.value = setTimeout(saveNote, 3000);
detectTaskMention();
};
const confirmDelete = () => {
@@ -249,6 +404,152 @@ const insertAtCursor = (snippet) => {
});
};
const detectTaskMention = async () => {
const content = editingNote.value.content || "";
const match = content.match(/@task\s+([^\n]{1,40})$/i);
if (!match || !props.spaceId) {
showTaskMention.value = false;
taskMentionResults.value = [];
taskMentionQuery.value = "";
return;
}
const query = match[1].trim();
taskMentionQuery.value = query;
if (!query) {
showTaskMention.value = false;
taskMentionResults.value = [];
return;
}
try {
taskMentionResults.value = await spaceStore.searchTasks(props.spaceId, query);
showTaskMention.value = taskMentionResults.value.length > 0;
} catch {
taskMentionResults.value = [];
showTaskMention.value = false;
}
};
const replaceTaskMentionText = (title) => {
editingNote.value.content = (editingNote.value.content || "").replace(/@task\s+([^\n]{1,40})$/i, `@task(${title})`);
};
const selectMentionTask = async (task) => {
replaceTaskMentionText(task.title);
showTaskMention.value = false;
taskMentionResults.value = [];
if (!props.spaceId || !editingNote.value.id) {
return;
}
try {
await spaceStore.linkTaskToNote(props.spaceId, task.id, editingNote.value.id);
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
alert("Unable to link task to this note.");
}
autoSave();
};
const insertTaskMention = (task) => {
if (!task?.title) {
return;
}
insertAtCursor(`@task(${task.title})`);
};
const refreshTaskPicker = async () => {
if (!props.spaceId) {
return;
}
taskPickerLoading.value = true;
try {
await Promise.all([spaceStore.fetchTasks(props.spaceId), spaceStore.fetchTaskStatuses(props.spaceId)]);
} finally {
taskPickerLoading.value = false;
}
};
const toggleTaskPicker = async () => {
showTaskPicker.value = !showTaskPicker.value;
if (showTaskPicker.value && !spaceStore.tasks.length) {
await refreshTaskPicker();
}
};
/**
* Parse all @task(Title) mentions in content and ensure each is linked.
* Called after every real save so new mentions are linked automatically.
*/
const syncTaskMentionLinks = async (content) => {
if (!props.spaceId || !editingNote.value.id) {
return;
}
const mentionTitles = new Set();
const rx = /@task\(([^)]+)\)/gi;
let m;
while ((m = rx.exec(content)) !== null) {
const title = (m[1] || "").trim();
if (title) {
mentionTitles.add(title.toLowerCase());
}
}
if (!mentionTitles.size) {
return;
}
let current;
try {
current = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
return;
}
const linkedTitles = new Set((current || []).map((t) => (t.title || "").toLowerCase()));
const toLink = [...mentionTitles].filter((title) => !linkedTitles.has(title));
if (!toLink.length) {
linkedTasks.value = current;
return;
}
await Promise.all(
toLink.map(async (title) => {
try {
const results = await spaceStore.searchTasks(props.spaceId, title);
const exact = results.find((t) => (t.title || "").toLowerCase() === title);
if (exact) {
await spaceStore.linkTaskToNote(props.spaceId, exact.id, editingNote.value.id);
}
} catch {
// best-effort — skip silently
}
}),
);
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
// ignore
}
};
const onPreviewClick = (event) => {
const anchor = event.target?.closest?.("a");
if (!anchor) {
return;
}
const href = anchor.getAttribute("href") || "";
if (!href.startsWith("#task:")) {
return;
}
event.preventDefault();
const taskId = href.slice("#task:".length);
if (!taskId) {
return;
}
const matchedTask = (linkedTasks.value || []).find((task) => task.id === taskId);
if (matchedTask) {
emit("open-linked-task", matchedTask);
}
};
onBeforeUnmount(() => {
clearTimeout(saveTimeout.value);
clearTimeout(saveStateTimeout.value);
@@ -257,6 +558,16 @@ onBeforeUnmount(() => {
onMounted(async () => {
await settingsStore.loadFeatureFlags();
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
if (props.spaceId && editingNote.value?.id) {
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
linkedTasks.value = [];
}
}
if (props.spaceId && !spaceStore.tasks.length) {
await refreshTaskPicker();
}
});
</script>
@@ -347,6 +658,133 @@ onMounted(async () => {
font-size: 0.9rem;
}
.task-mention-panel {
margin-top: 0.45rem;
border: 1px solid #dbe4f0;
border-radius: 10px;
background: #fbfdff;
padding: 0.5rem;
max-height: 220px;
overflow-y: auto;
}
.task-mention-option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
padding: 0.35rem 0.45rem;
border-radius: 6px;
text-align: left;
}
.task-mention-option:hover {
background: #eef3ff;
}
.task-picker {
background: #fff;
min-height: 300px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-picker-header {
background: #f8f9fa;
min-height: 34px;
}
.task-picker-search {
background: #fff;
}
.task-picker-list {
overflow-y: auto;
max-height: 520px;
padding: 0.25rem;
}
.task-picker-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
border-radius: 8px;
padding: 0.35rem 0.45rem;
text-align: left;
gap: 0.4rem;
color: #333;
}
.task-picker-item:hover {
background: #eef3ff;
}
.task-picker-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-picker-empty {
padding: 0.7rem;
}
.task-picker-item small {
font-size: 0.7rem;
color: #6b7280;
}
.task-picker .btn-link {
text-decoration: none;
}
.task-picker-item {
flex-wrap: wrap;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, #ffffff 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, #ffffff 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .editor-toolbar {
border-bottom-color: #3a3f4b;
@@ -373,4 +811,63 @@ onMounted(async () => {
:root[data-bs-theme="dark"] .danger-zone-copy {
color: #fca5a5;
}
:root[data-bs-theme="dark"] .task-mention-panel {
border-color: #3a4558;
background: #1f2733;
}
:root[data-bs-theme="dark"] .task-mention-option:hover {
background: #2b3646;
}
:root[data-bs-theme="dark"] .task-picker {
border-color: #3a3f4b !important;
background: #21252e;
}
:root[data-bs-theme="dark"] .task-picker-header {
background: #21252e;
border-bottom-color: #3a3f4b !important;
}
:root[data-bs-theme="dark"] .task-picker-search {
background: #21252e;
border-bottom-color: #3a3f4b !important;
}
:root[data-bs-theme="dark"] .task-picker-search .form-control {
background: #1f2430;
border-color: #3a3f4b;
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .task-picker-item {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .task-picker-item:hover {
background: #2d3748;
}
:root[data-bs-theme="dark"] .task-picker-item small {
color: #a8b4c7;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
}
</style>

View File

@@ -24,7 +24,7 @@
</div>
</header>
<div class="markdown-body" v-html="renderedMarkdown"></div>
<div class="markdown-body" v-html="renderedMarkdown" @click="onMarkdownClick"></div>
</article>
</template>
@@ -46,13 +46,61 @@ const props = defineProps({
type: String,
default: "",
},
linkedTasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["open-linked-task"]);
const renderedMarkdown = computed(() => {
const html = renderMarkdown(props.note.content || "");
const html = renderMarkdown(enrichTaskMentions(props.note.content || ""));
return DOMPurify.sanitize(html);
});
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
const taskByTitle = computed(() => {
const map = new Map();
for (const task of props.linkedTasks || []) {
const key = normalizeTaskTitle(task.title);
if (!key || map.has(key)) {
continue;
}
map.set(key, task);
}
return map;
});
const enrichTaskMentions = (content) => {
if (!content) {
return "";
}
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
const title = (rawTitle || "").trim();
if (!title) {
return full;
}
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
if (!linkedTask?.id) {
return full;
}
const statusName = (linkedTask.status_name || "Unknown").trim();
const safeTitle = title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const safeStatusName = statusName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const statusColor = (linkedTask.status_color || "").trim();
const safeStatusColor = statusColor.replace(/"/g, "").replace(/</g, "").replace(/>/g, "");
const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : "";
return `<a href="#task:${linkedTask.id}" class="task-inline-link"${styleAttr}><i class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></i><span class="task-inline-title">${safeTitle}</span><span class="task-inline-status">${safeStatusName}</span></a>`;
});
};
const categoryLabel = computed(() => {
const categoryId = props.note.category_id;
if (!categoryId) {
@@ -79,6 +127,29 @@ const categoryLabel = computed(() => {
});
const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
const onMarkdownClick = (event) => {
const anchor = event.target?.closest?.("a");
if (!anchor) {
return;
}
const href = anchor.getAttribute("href") || "";
if (!href.startsWith("#task:")) {
return;
}
event.preventDefault();
const taskId = href.slice("#task:".length);
if (!taskId) {
return;
}
const matchedTask = (props.linkedTasks || []).find((task) => task.id === taskId);
if (matchedTask) {
emit("open-linked-task", matchedTask);
}
};
</script>
<style scoped>
@@ -146,6 +217,43 @@ const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
background: #fff4e6;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, #ffffff 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, #ffffff 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
@@ -219,4 +327,21 @@ const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
background: #1e2430;
color: #94a3b8;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
}
</style>

View File

@@ -0,0 +1,892 @@
<template>
<section class="task-board">
<div class="task-board-header">
<div class="task-title-wrap">
<h4 class="mb-0">Tasks</h4>
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
</div>
<button class="btn btn-primary" @click="emit('create-task')">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
New Task
</button>
</div>
<div class="task-filters">
<select v-model="filterCategory" class="form-select" @change="emitFilters">
<option value="">All categories</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
<select v-model="filterStatus" class="form-select" @change="emitFilters">
<option value="">All statuses</option>
<option v-for="status in statuses" :key="status.id" :value="status.id">
{{ status.name }}
</option>
</select>
<select v-model="filterParent" class="form-select" @change="emitFilters">
<option value="">Any parent</option>
<option value="root">Top-level only</option>
<option v-for="task in parentTaskOptions" :key="task.id" :value="task.id">
{{ task.title }}
</option>
</select>
</div>
<div class="status-lane">
<div class="lane-header">
<strong>Status Progression</strong>
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
</div>
<div class="status-list">
<div
v-for="status in statuses"
:key="status.id"
class="status-item"
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
draggable="true"
@dragstart="onStatusDragStart(status.id)"
@dragover.prevent="onStatusDragOver(status.id)"
@dragleave="onStatusDragLeave(status.id)"
@drop.prevent="onStatusDrop(status.id)"
@dragend="onStatusDragEnd"
>
<span class="drag-handle" aria-hidden="true">
<i class="mdi mdi-drag-horizontal-variant"></i>
</span>
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
<span class="status-name">{{ status.name }}</span>
<div class="status-actions">
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
</div>
</div>
</div>
</div>
<div class="task-status-groups">
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
<section v-for="section in statusSections" :key="section.status.id" class="status-group">
<header class="status-group-header" :style="statusHeaderStyle(section.status)">
<div class="status-group-title-wrap">
<span class="status-group-dot" :style="{ backgroundColor: section.status.color || '#7c8596' }"></span>
<span class="status-group-title">{{ section.status.name }}</span>
</div>
<span class="status-group-count">{{ section.parentTasks.length }}</span>
</header>
<div v-if="!section.parentTasks.length" class="status-empty">No tasks in this status.</div>
<div v-for="parentTask in section.parentTasks" :key="parentTask.id" class="task-tree-row level-0">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', parentTask)"
@keydown.enter="emit('select-task', parentTask)"
@keydown.space.prevent="emit('select-task', parentTask)"
>
<span class="tree-toggle" @click.stop="toggleExpanded(parentTask)">
<i v-if="hasChildren(parentTask)" :class="isExpanded(parentTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
</span>
<span class="task-main">
<strong>{{ parentTask.title }}</strong>
<small class="text-muted">{{ parentTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(parentTask.status_id)}`" @click="toggleStatusMenu(parentTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(parentTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(parentTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: parentTask.status_id === status.id }"
@click="onTaskStatusChange(parentTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="parentTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div v-if="isExpanded(parentTask.id)">
<div v-for="childTask in childrenFor(parentTask.id)" :key="childTask.id" class="task-tree-row level-1">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', childTask)"
@keydown.enter="emit('select-task', childTask)"
@keydown.space.prevent="emit('select-task', childTask)"
>
<span class="tree-toggle" @click.stop="toggleExpanded(childTask)">
<i v-if="hasChildren(childTask)" :class="isExpanded(childTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
</span>
<span class="task-main">
<strong>{{ childTask.title }}</strong>
<small class="text-muted">{{ childTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(childTask.status_id)}`" @click="toggleStatusMenu(childTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(childTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(childTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: childTask.status_id === status.id }"
@click="onTaskStatusChange(childTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="childTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div v-if="isExpanded(childTask.id)">
<div v-for="grandchildTask in childrenFor(childTask.id)" :key="grandchildTask.id" class="task-tree-row level-2">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', grandchildTask)"
@keydown.enter="emit('select-task', grandchildTask)"
@keydown.space.prevent="emit('select-task', grandchildTask)"
>
<span class="tree-toggle"></span>
<span class="task-main">
<strong>{{ grandchildTask.title }}</strong>
<small class="text-muted">{{ grandchildTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(grandchildTask.status_id)}`" @click="toggleStatusMenu(grandchildTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(grandchildTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(grandchildTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: grandchildTask.status_id === status.id }"
@click="onTaskStatusChange(grandchildTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="grandchildTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<teleport to="body">
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
</div>
<div class="modal-body">
<label class="form-label" for="taskStatusName">Status Name</label>
<input id="taskStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
<label class="form-label mt-3" for="taskStatusColor">Status Color</label>
<div class="status-color-row">
<input id="taskStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
</div>
<section v-if="statusMode === 'edit'" class="danger-zone mt-4" aria-labelledby="status-danger-zone-title">
<h6 id="status-danger-zone-title" class="danger-zone-title">Danger Zone</h6>
<p class="danger-zone-copy mb-2">Deleting this status is permanent and cannot be undone.</p>
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
</section>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
<button type="button" class="btn btn-primary" @click="submitStatusForm">
{{ statusMode === "create" ? "Create" : "Save" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="showStatusModal" class="modal-backdrop fade show"></div>
</teleport>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({
tasks: {
type: Array,
default: () => [],
},
statuses: {
type: Array,
default: () => [],
},
categoryOptions: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status"]);
const filterCategory = ref("");
const filterStatus = ref("");
const filterParent = ref("");
const showStatusModal = ref(false);
const statusMode = ref("create");
const editingStatusId = ref("");
const draggedStatusId = ref("");
const dragOverStatusId = ref("");
const expandedTaskIds = ref({});
const openStatusMenuTaskId = ref("");
const statusForm = ref({
name: "",
color: "#7c8596",
});
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
const tasksById = computed(() => {
const map = new Map();
for (const task of props.tasks) {
map.set(task.id, task);
}
return map;
});
const tasksByParentId = computed(() => {
const map = new Map();
for (const task of props.tasks) {
if (!task.parent_task_id) {
continue;
}
const existing = map.get(task.parent_task_id) || [];
existing.push(task);
map.set(task.parent_task_id, existing);
}
for (const [key, children] of map) {
map.set(
key,
[...children].sort((a, b) => (a.title || "").localeCompare(b.title || "")),
);
}
return map;
});
const parentTasks = computed(() => props.tasks.filter((task) => !task.parent_task_id || !tasksById.value.has(task.parent_task_id)));
const statusSections = computed(() =>
props.statuses.map((status) => ({
status,
parentTasks: parentTasks.value.filter((task) => task.status_id === status.id),
})),
);
const emitFilters = () => {
emit("filter-change", {
categoryId: filterCategory.value || null,
statusId: filterStatus.value || null,
parentTaskId: filterParent.value || null,
});
};
const statusHeaderStyle = (status) => {
const color = status.color || "#7c8596";
return {
borderColor: color,
};
};
const statusColor = (statusId) => props.statuses.find((status) => status.id === statusId)?.color || "#7c8596";
const statusName = (statusId) => props.statuses.find((status) => status.id === statusId)?.name || "Unknown";
const statusDotStyle = (statusId) => ({
backgroundColor: statusColor(statusId),
});
const isStatusMenuOpen = (taskId) => openStatusMenuTaskId.value === taskId;
const toggleStatusMenu = (taskId) => {
openStatusMenuTaskId.value = openStatusMenuTaskId.value === taskId ? "" : taskId;
};
const closeStatusMenu = () => {
openStatusMenuTaskId.value = "";
};
const onDocumentClick = () => {
closeStatusMenu();
};
const childrenFor = (parentId) => tasksByParentId.value.get(parentId) || [];
const hasChildren = (task) => childrenFor(task.id).length > 0;
const isExpanded = (taskId) => !!expandedTaskIds.value[taskId];
const toggleExpanded = (task) => {
if (!hasChildren(task)) {
return;
}
expandedTaskIds.value = {
...expandedTaskIds.value,
[task.id]: !expandedTaskIds.value[task.id],
};
};
const onTaskStatusChange = (task, statusId) => {
if (!task?.id || !statusId || task.status_id === statusId) {
closeStatusMenu();
return;
}
emit("update-task-status", {
taskId: task.id,
currentStatusId: task.status_id,
targetStatusId: statusId,
});
closeStatusMenu();
};
onMounted(() => {
document.addEventListener("click", onDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick);
});
const onStatusDragStart = (statusId) => {
draggedStatusId.value = statusId;
};
const onStatusDragOver = (statusId) => {
dragOverStatusId.value = statusId;
};
const onStatusDragLeave = (statusId) => {
if (dragOverStatusId.value === statusId) {
dragOverStatusId.value = "";
}
};
const onStatusDrop = (targetStatusId) => {
if (!draggedStatusId.value || draggedStatusId.value === targetStatusId) {
onStatusDragEnd();
return;
}
const ordered = props.statuses.map((item) => item.id);
const fromIndex = ordered.indexOf(draggedStatusId.value);
const targetIndex = ordered.indexOf(targetStatusId);
if (fromIndex < 0 || targetIndex < 0) {
onStatusDragEnd();
return;
}
ordered.splice(fromIndex, 1);
const insertIndex = ordered.indexOf(targetStatusId);
ordered.splice(insertIndex, 0, draggedStatusId.value);
emit("reorder-status", ordered);
onStatusDragEnd();
};
const onStatusDragEnd = () => {
draggedStatusId.value = "";
dragOverStatusId.value = "";
};
const closeStatusModal = () => {
showStatusModal.value = false;
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = {
name: "",
color: "#7c8596",
};
};
const openCreateStatusModal = () => {
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = {
name: "",
color: "#7c8596",
};
showStatusModal.value = true;
};
const openEditStatusModal = (status) => {
statusMode.value = "edit";
editingStatusId.value = status.id;
statusForm.value = {
name: status.name || "",
color: status.color || "#7c8596",
};
showStatusModal.value = true;
};
const submitStatusForm = () => {
const name = statusForm.value.name?.trim();
if (!name) {
return;
}
const color = statusForm.value.color?.trim() || "";
if (statusMode.value === "create") {
emit("create-status", { name, color });
} else {
if (!editingStatusId.value) {
return;
}
emit("rename-status", {
id: editingStatusId.value,
name,
color,
});
}
closeStatusModal();
};
const deleteStatusFromModal = () => {
if (statusMode.value !== "edit" || !editingStatusId.value) {
return;
}
emit("delete-status", {
id: editingStatusId.value,
name: statusForm.value.name?.trim() || "",
color: statusForm.value.color?.trim() || "",
});
closeStatusModal();
};
</script>
<style scoped>
.task-board {
display: flex;
flex-direction: column;
gap: 1rem;
}
.task-board-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.task-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.status-lane {
border: 1px solid #d9e2ec;
border-radius: 12px;
padding: 0.75rem;
background: linear-gradient(180deg, #fcfdff 0%, #f5f8fc 100%);
}
.lane-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.status-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.45rem;
border-radius: 8px;
background: #ffffff;
border: 1px solid #e4e9f0;
cursor: grab;
}
.status-item.is-drag-over {
border-color: #7aa2f7;
background: #eef3ff;
}
.drag-handle {
color: #74839a;
display: inline-flex;
align-items: center;
justify-content: center;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-name {
flex: 1;
font-weight: 600;
}
.status-actions {
display: inline-flex;
gap: 0.35rem;
}
.task-status-groups {
display: flex;
flex-direction: column;
gap: 1rem;
}
.status-group {
border: 1px solid #dbe4f0;
border-radius: 12px;
overflow: visible;
background: #fff;
}
.status-group-header {
display: flex;
align-items: center;
justify-content: space-between;
border-left: 6px solid transparent;
background: #f8fbff;
border-bottom: 1px solid #edf2f8;
padding: 0.65rem 0.85rem;
}
.status-group-title-wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.status-group-title {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.status-group-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-group-count {
color: #5f6f87;
font-weight: 600;
}
.status-empty {
padding: 0.75rem 0.85rem;
color: #7a8799;
font-size: 0.9rem;
}
.task-tree-row {
border-bottom: 1px solid #edf2f8;
}
.task-tree-row:last-child {
border-bottom: 0;
}
.task-tree-row.level-1 .task-row {
padding-left: 2.1rem;
}
.task-tree-row.level-2 .task-row {
padding-left: 3.5rem;
}
.task-row {
width: 100%;
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 0.65rem;
align-items: center;
border: 0;
background: #fff;
text-align: left;
padding: 0.7rem 0.85rem;
}
.task-row:hover {
background: #f4f8ff;
}
.status-group-header {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.status-group > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.tree-toggle {
width: 1.25rem;
color: #5f6f87;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
padding: 0;
}
.task-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.task-main strong,
.task-main small {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-status-menu {
position: relative;
display: inline-flex;
}
.status-trigger {
width: 28px;
height: 28px;
border: 0;
background: transparent;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.status-trigger:hover {
background: #eef3f9;
}
.status-trigger-dot {
width: 14px;
height: 14px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(67, 81, 98, 0.25);
border-radius: 999px;
}
.status-popup {
position: absolute;
right: 0;
top: calc(100% + 0.3rem);
min-width: 190px;
background: #151a22;
border: 1px solid #2a3343;
border-radius: 10px;
box-shadow: 0 12px 28px rgba(5, 9, 15, 0.35);
padding: 0.35rem;
z-index: 40;
}
.status-option {
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
color: #e8edf5;
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.5rem;
text-align: left;
}
.status-option:hover,
.status-option.selected {
background: rgba(255, 255, 255, 0.09);
}
.status-option-dot {
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid;
background: transparent;
}
.status-option-label {
font-size: 0.86rem;
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 600;
}
.status-option-check {
color: #e8edf5;
font-size: 0.95rem;
}
.empty-state {
padding: 1rem;
color: #6c757d;
}
.status-color-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem;
align-items: center;
}
.danger-zone {
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: #fff5f5;
padding: 0.75rem;
}
.danger-zone-title {
color: #9f1c1c;
margin: 0;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
@media (max-width: 900px) {
.task-filters {
grid-template-columns: 1fr;
}
.task-row {
grid-template-columns: 24px 1fr;
}
.status-popup {
right: -0.2rem;
min-width: 170px;
}
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .status-lane {
background: linear-gradient(180deg, #1e2330 0%, #1a1d27 100%);
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-item {
background: #252b38;
border-color: #3a3f4b;
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-item.is-drag-over {
border-color: #7aa2f7;
background: #1e2d4a;
}
:root[data-bs-theme="dark"] .drag-handle {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .status-group {
background: #1e2230;
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-group-header {
background: #232840;
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-group-title {
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-group-count {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-empty {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .task-tree-row {
border-bottom-color: #2e3444;
}
:root[data-bs-theme="dark"] .task-row {
background: #1e2230;
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .task-row:hover {
background: #252d40;
}
:root[data-bs-theme="dark"] .tree-toggle {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .task-main small {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-trigger:hover {
background: #2e3448;
}
:root[data-bs-theme="dark"] .status-trigger-dot {
border-color: #1e2230;
box-shadow: 0 0 0 1px rgba(180, 195, 220, 0.2);
}
:root[data-bs-theme="dark"] .empty-state {
color: #7a8fa8;
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-xl modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ localTask.id ? "Task Detail" : "Create Task" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12 col-lg-7">
<label class="form-label">Title</label>
<input v-model="localTask.title" class="form-control" type="text" maxlength="255" />
<label class="form-label mt-3">Description</label>
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
<label class="form-label mt-3">Category</label>
<select v-model="localTask.category_id" class="form-select">
<option value="">Uncategorized</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">{{ category.label }}</option>
</select>
<label class="form-label mt-3">Parent Task</label>
<select v-model="localTask.parent_task_id" class="form-select">
<option value="">No parent (top level)</option>
<option v-for="option in parentTaskOptions" :key="option.id" :value="option.id">{{ option.title }}</option>
</select>
</div>
<div class="col-12 col-lg-5">
<label class="form-label">Status</label>
<select v-model="localTask.status_id" class="form-select">
<option v-for="status in statuses" :key="status.id" :value="status.id">{{ status.name }}</option>
</select>
<div class="status-progress mt-3">
<div v-for="status in statuses" :key="status.id" class="progress-step" :class="stepClass(status)">
<span class="dot" :style="{ borderColor: status.color || '#7c8596', backgroundColor: isReached(status) ? status.color || '#7c8596' : 'transparent' }"></span>
<span>{{ status.name }}</span>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-outline-secondary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'backward' })">Revert</button>
<button class="btn btn-outline-primary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'forward' })">Advance</button>
</div>
<div class="mt-4">
<h6>Subtasks</h6>
<div v-if="!subtasks.length" class="text-muted small">No subtasks yet.</div>
<button v-for="subtask in subtasks" :key="subtask.id" class="subtask-row" @click="emit('open-task', subtask)">
<span>{{ subtask.title }}</span>
<small>L{{ subtask.depth + 1 }}</small>
</button>
<button v-if="canAddSubtask" class="btn btn-sm btn-outline-primary mt-2" @click="emit('create-subtask', localTask)">Add Subtask</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Close</button>
<button v-if="localTask.id" type="button" class="btn btn-danger" @click="emit('delete-task', localTask)">Delete</button>
<button type="button" class="btn btn-primary" @click="saveTask">Save</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
task: {
type: Object,
default: () => ({}),
},
statuses: {
type: Array,
default: () => [],
},
categoryOptions: {
type: Array,
default: () => [],
},
parentTaskOptions: {
type: Array,
default: () => [],
},
subtasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["close", "save-task", "delete-task", "transition", "create-subtask", "open-task"]);
const localTask = ref({});
watch(
() => props.task,
(value) => {
localTask.value = {
title: "",
description: "",
category_id: "",
status_id: props.statuses[0]?.id || "",
parent_task_id: "",
note_links: [],
...value,
category_id: value?.category_id || "",
parent_task_id: value?.parent_task_id || "",
note_links: value?.note_links || [],
};
},
{ immediate: true },
);
const canAddSubtask = computed(() => !!localTask.value.id && (localTask.value.depth ?? 0) < 2);
const isReached = (status) => {
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
return status.order <= current;
};
const stepClass = (status) => {
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
return {
current: status.order === current,
done: status.order < current,
};
};
const saveTask = () => {
emit("save-task", {
...localTask.value,
category_id: localTask.value.category_id || null,
parent_task_id: localTask.value.parent_task_id || null,
});
};
</script>
<style scoped>
.status-progress {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.progress-step {
display: flex;
align-items: center;
gap: 0.45rem;
color: #627086;
}
.progress-step.current {
color: #0f172a;
font-weight: 700;
}
.progress-step.done {
color: #1f7a4d;
}
.dot {
width: 12px;
height: 12px;
border-radius: 999px;
border: 2px solid;
}
.subtask-row {
width: 100%;
margin-top: 0.35rem;
border: 1px solid #dbe4f0;
border-radius: 8px;
background: #f8fbff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0.5rem;
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .progress-step {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .progress-step.current {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .progress-step.done {
color: #4ade80;
}
:root[data-bs-theme="dark"] .subtask-row {
background: #252b38;
border-color: #3a3f4b;
color: #c8d3e6;
}
</style>

View File

@@ -13,9 +13,12 @@ export const useSpaceStore = defineStore("space", () => {
const notesLoading = ref(false);
const categories = ref([]);
const categoryTree = ref([]);
const tasks = ref([]);
const taskStatuses = ref([]);
const noteLinkedTasks = ref([]);
const refreshSpaceData = async (spaceId) => {
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId)]);
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]);
};
const fetchSpaces = async () => {
@@ -208,6 +211,130 @@ export const useSpaceStore = defineStore("space", () => {
searchResults.value = [];
};
const fetchTaskStatuses = async (spaceId) => {
if (!spaceId) {
taskStatuses.value = [];
return [];
}
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-statuses`);
taskStatuses.value = response.data || [];
return taskStatuses.value;
} catch (error) {
console.error("Error fetching task statuses:", error);
taskStatuses.value = [];
return [];
}
};
const createTaskStatus = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload);
await fetchTaskStatuses(spaceId);
return response.data;
};
const updateTaskStatus = async (spaceId, statusId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`, payload);
await fetchTaskStatuses(spaceId);
return response.data;
};
const deleteTaskStatus = async (spaceId, statusId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`);
await fetchTaskStatuses(spaceId);
};
const reorderTaskStatuses = async (spaceId, orderedStatusIds) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/reorder`, {
ordered_status_ids: orderedStatusIds,
});
taskStatuses.value = response.data || [];
return taskStatuses.value;
};
const fetchTasks = async (spaceId, filters = {}) => {
if (!spaceId) {
tasks.value = [];
return [];
}
const params = {};
if (filters.categoryId) {
params.categoryId = filters.categoryId;
}
if (filters.statusId) {
params.statusId = filters.statusId;
}
if (typeof filters.parentTaskId === "string") {
params.parentTaskId = filters.parentTaskId;
}
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks`, { params });
tasks.value = response.data || [];
return tasks.value;
} catch (error) {
console.error("Error fetching tasks:", error);
tasks.value = [];
return [];
}
};
const searchTasks = async (spaceId, query) => {
if (!spaceId || !query?.trim()) {
return [];
}
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks/search`, { params: { q: query } });
return response.data || [];
};
const getTask = async (spaceId, taskId) => {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks/${taskId}`);
return response.data;
};
const createTask = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks`, payload);
await fetchTasks(spaceId);
return response.data;
};
const updateTask = async (spaceId, taskId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/tasks/${taskId}`, payload);
await fetchTasks(spaceId);
return response.data;
};
const deleteTask = async (spaceId, taskId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/tasks/${taskId}`);
await fetchTasks(spaceId);
};
const transitionTask = async (spaceId, taskId, direction) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks/${taskId}/transition`, { direction });
await fetchTasks(spaceId);
return response.data;
};
const fetchTasksForNote = async (spaceId, noteId) => {
if (!spaceId || !noteId) {
noteLinkedTasks.value = [];
return [];
}
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/notes/${noteId}/tasks`);
noteLinkedTasks.value = response.data || [];
return noteLinkedTasks.value;
};
const linkTaskToNote = async (spaceId, taskId, noteId) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks/${taskId}/notes`, { note_id: noteId });
return response.data;
};
const unlinkTaskFromNote = async (spaceId, taskId, noteId) => {
const response = await apiClient.delete(`/api/v1/spaces/${spaceId}/tasks/${taskId}/notes/${noteId}`);
return response.data;
};
return {
spaces,
currentSpace,
@@ -217,6 +344,9 @@ export const useSpaceStore = defineStore("space", () => {
notesLoading,
categories,
categoryTree,
tasks,
taskStatuses,
noteLinkedTasks,
fetchSpaces,
selectSpace,
fetchNotes,
@@ -232,5 +362,20 @@ export const useSpaceStore = defineStore("space", () => {
deleteNote,
searchNotes,
clearSearchResults,
fetchTaskStatuses,
createTaskStatus,
updateTaskStatus,
deleteTaskStatus,
reorderTaskStatuses,
fetchTasks,
searchTasks,
getTask,
createTask,
updateTask,
deleteTask,
transitionTask,
fetchTasksForNote,
linkTaskToNote,
unlinkTaskFromNote,
};
});