feat: task system
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
880
backend/internal/application/services/task_service.go
Normal file
880
backend/internal/application/services/task_service.go
Normal 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
|
||||
}
|
||||
37
backend/internal/domain/entities/task.go
Normal file
37
backend/internal/domain/entities/task.go
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
185
backend/internal/infrastructure/database/task_repository.go
Normal file
185
backend/internal/infrastructure/database/task_repository.go
Normal 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
|
||||
}
|
||||
398
backend/internal/interfaces/handlers/task_handler.go
Normal file
398
backend/internal/interfaces/handlers/task_handler.go
Normal 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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
const safeStatusName = statusName.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
|
||||
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>
|
||||
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
const safeStatusName = statusName.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
|
||||
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>
|
||||
|
||||
892
frontend/src/components/TaskBoard.vue
Normal file
892
frontend/src/components/TaskBoard.vue
Normal 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>
|
||||
207
frontend/src/components/TaskDetailModal.vue
Normal file
207
frontend/src/components/TaskDetailModal.vue
Normal 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>
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user