Compare commits
4 Commits
b9ca845b9c
...
503d2415e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503d2415e6 | ||
|
|
74d8899eec | ||
|
|
295e03feb4 | ||
|
|
b09137eca5 |
@@ -10,8 +10,6 @@ JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production
|
|||||||
ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882
|
ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882
|
||||||
FRONTEND_URL="http://localhost"
|
FRONTEND_URL="http://localhost"
|
||||||
|
|
||||||
VITE_API_BASE_URL="http://localhost"
|
|
||||||
|
|
||||||
# Default Admin
|
# Default Admin
|
||||||
DEFAULT_ADMIN_EMAIL=admin@notely.local
|
DEFAULT_ADMIN_EMAIL=admin@notely.local
|
||||||
DEFAULT_ADMIN_USERNAME=admin
|
DEFAULT_ADMIN_USERNAME=admin
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./devops/docker/Dockerfile
|
file: ./devops/docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
|
||||||
VITE_API_BASE_URL=${{ secrets.VITE_API_BASE_URL }}
|
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE_NAME }}:latest
|
${{ env.IMAGE_NAME }}:latest
|
||||||
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }}
|
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ Required or commonly used:
|
|||||||
- `JWT_SECRET`
|
- `JWT_SECRET`
|
||||||
- `ENCRYPTION_KEY`
|
- `ENCRYPTION_KEY`
|
||||||
- `FRONTEND_URL`
|
- `FRONTEND_URL`
|
||||||
- `VITE_API_BASE_URL`
|
|
||||||
- `DEFAULT_ADMIN_EMAIL`
|
- `DEFAULT_ADMIN_EMAIL`
|
||||||
- `DEFAULT_ADMIN_USERNAME`
|
- `DEFAULT_ADMIN_USERNAME`
|
||||||
- `DEFAULT_ADMIN_PASSWORD`
|
- `DEFAULT_ADMIN_PASSWORD`
|
||||||
@@ -41,7 +40,6 @@ Optional backend runtime values that Docker Compose will also pass through if pr
|
|||||||
- MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin`
|
- MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin`
|
||||||
- Backend port: `8080`
|
- Backend port: `8080`
|
||||||
- Public frontend URL: `http://localhost`
|
- Public frontend URL: `http://localhost`
|
||||||
- Browser API base URL for container builds: `http://localhost`
|
|
||||||
|
|
||||||
## 2. `backend/.env`
|
## 2. `backend/.env`
|
||||||
|
|
||||||
@@ -107,13 +105,12 @@ cp .env.example .env
|
|||||||
|
|
||||||
### Frontend Variables In `frontend/.env.example`
|
### Frontend Variables In `frontend/.env.example`
|
||||||
|
|
||||||
- `VITE_API_BASE_URL`
|
|
||||||
- `VITE_ENV`
|
- `VITE_ENV`
|
||||||
- `VITE_ENABLE_ANALYTICS`
|
- `VITE_ENABLE_ANALYTICS`
|
||||||
|
|
||||||
### Variables Currently Relevant To The Frontend App
|
### Variables Currently Relevant To The Frontend App
|
||||||
|
|
||||||
- `VITE_API_BASE_URL`: used by the API client
|
- API requests are sent to the current browser origin (same-origin runtime behavior)
|
||||||
|
|
||||||
The other example values are safe to keep, but the current checked-in frontend code does not actively consume them.
|
The other example values are safe to keep, but the current checked-in frontend code does not actively consume them.
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ Check `REDIS_ADDR`, `REDIS_PASSWORD`, and `REDIS_DB`. For local defaults, Redis
|
|||||||
Check:
|
Check:
|
||||||
|
|
||||||
- backend is running on port `8080`
|
- backend is running on port `8080`
|
||||||
- frontend `VITE_API_BASE_URL`
|
- frontend and API are reachable through the same host/origin
|
||||||
- Vite proxy settings in `frontend/vite.config.js`
|
- Vite proxy settings in `frontend/vite.config.js`
|
||||||
|
|
||||||
### OAuth callback redirects to the wrong URL
|
### OAuth callback redirects to the wrong URL
|
||||||
|
|||||||
@@ -288,12 +288,12 @@ func main() {
|
|||||||
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
|
||||||
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
|
||||||
|
|
||||||
// Task status endpoints
|
// Task status endpoints (scoped to task list)
|
||||||
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.ListStatuses).Methods("GET")
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
|
||||||
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.CreateStatus).Methods("POST")
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
|
||||||
api.HandleFunc("/spaces/{spaceId}/task-statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
|
||||||
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
|
||||||
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
|
||||||
|
|
||||||
// File explorer endpoints (space-scoped)
|
// File explorer endpoints (space-scoped)
|
||||||
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
|
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ type ReorderTaskStatusesRequest struct {
|
|||||||
// TaskStatusDTO represents a task status in API responses.
|
// TaskStatusDTO represents a task status in API responses.
|
||||||
type TaskStatusDTO struct {
|
type TaskStatusDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SpaceID string `json:"space_id"`
|
TaskListID string `json:"task_list_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Color string `json:"color,omitempty"`
|
Color string `json:"color,omitempty"`
|
||||||
Order int `json:"order"`
|
Order int `json:"order"`
|
||||||
@@ -621,7 +621,7 @@ func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
|
|||||||
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
|
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
|
||||||
return &TaskStatusDTO{
|
return &TaskStatusDTO{
|
||||||
ID: status.ID.Hex(),
|
ID: status.ID.Hex(),
|
||||||
SpaceID: status.SpaceID.Hex(),
|
TaskListID: status.TaskListID.Hex(),
|
||||||
Name: status.Name,
|
Name: status.Name,
|
||||||
Color: status.Color,
|
Color: status.Color,
|
||||||
Order: status.Order,
|
Order: status.Order,
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ func NewTaskService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.ObjectID) error {
|
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, taskListID bson.ObjectID) error {
|
||||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.Ob
|
|||||||
|
|
||||||
for idx, status := range defaults {
|
for idx, status := range defaults {
|
||||||
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
|
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
|
||||||
SpaceID: spaceID,
|
TaskListID: taskListID,
|
||||||
Name: status.name,
|
Name: status.name,
|
||||||
Color: status.color,
|
Color: status.color,
|
||||||
Order: idx,
|
Order: idx,
|
||||||
@@ -142,9 +142,9 @@ func (s *TaskService) validateNoteLinks(ctx context.Context, spaceID bson.Object
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskService) validateStatus(ctx context.Context, spaceID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
|
func (s *TaskService) validateStatus(ctx context.Context, taskListID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
|
||||||
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
|
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
|
||||||
if err != nil || status.SpaceID != spaceID {
|
if err != nil || status.TaskListID != taskListID {
|
||||||
return nil, errors.New("invalid task status")
|
return nil, errors.New("invalid task status")
|
||||||
}
|
}
|
||||||
return status, nil
|
return status, nil
|
||||||
@@ -165,11 +165,11 @@ func (s *TaskService) resolveDepthAndParent(ctx context.Context, spaceID bson.Ob
|
|||||||
return depth, nil
|
return depth, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, spaceID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
|
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, taskListID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
|
||||||
if currentStatusID == requestedStatusID {
|
if currentStatusID == requestedStatusID {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -205,10 +205,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
|||||||
return nil, errors.New("insufficient permissions")
|
return nil, errors.New("insufficient permissions")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
|
parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("invalid parent task")
|
return nil, errors.New("invalid parent task")
|
||||||
@@ -226,6 +222,10 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
noteLinks, err := toObjectIDs(req.NoteLinks)
|
noteLinks, err := toObjectIDs(req.NoteLinks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -234,7 +234,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -252,7 +252,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
|||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return nil, errors.New("invalid task status")
|
return nil, errors.New("invalid task status")
|
||||||
}
|
}
|
||||||
if _, validateErr := s.validateStatus(ctx, spaceID, parsedStatusID); validateErr != nil {
|
if _, validateErr := s.validateStatus(ctx, taskListID, parsedStatusID); validateErr != nil {
|
||||||
return nil, validateErr
|
return nil, validateErr
|
||||||
}
|
}
|
||||||
statusID = parsedStatusID
|
statusID = parsedStatusID
|
||||||
@@ -299,7 +299,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b
|
|||||||
return nil, errors.New("task not found")
|
return nil, errors.New("task not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
status, err := s.validateStatus(ctx, spaceID, task.StatusID)
|
status, err := s.validateStatus(ctx, task.TaskListID, task.StatusID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -331,9 +331,6 @@ func (s *TaskService) ListTasks(
|
|||||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
filters := map[string]any{}
|
filters := map[string]any{}
|
||||||
if taskListID != nil && strings.TrimSpace(*taskListID) != "" {
|
if taskListID != nil && strings.TrimSpace(*taskListID) != "" {
|
||||||
@@ -403,23 +400,33 @@ func (s *TaskService) ListTasksLinkedToNote(ctx context.Context, spaceID, noteID
|
|||||||
if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil {
|
if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil {
|
||||||
return nil, errors.New("note not found")
|
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})
|
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect statuses per task list
|
||||||
|
statusCache := map[bson.ObjectID]map[bson.ObjectID]*entities.TaskStatus{}
|
||||||
|
getStatus := func(taskListID, statusID bson.ObjectID) *entities.TaskStatus {
|
||||||
|
byID, ok := statusCache[taskListID]
|
||||||
|
if !ok {
|
||||||
|
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
byID = make(map[bson.ObjectID]*entities.TaskStatus, len(statuses))
|
||||||
|
for _, st := range statuses {
|
||||||
|
byID[st.ID] = st
|
||||||
|
}
|
||||||
|
statusCache[taskListID] = byID
|
||||||
|
}
|
||||||
|
return byID[statusID]
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]*dto.TaskWithStatusDTO, 0, len(tasks))
|
result := make([]*dto.TaskWithStatusDTO, 0, len(tasks))
|
||||||
for _, task := range tasks {
|
for _, task := range tasks {
|
||||||
status := statusByID[task.StatusID]
|
status := getStatus(task.TaskListID, task.StatusID)
|
||||||
if status == nil {
|
if status == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -509,10 +516,10 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
|
|||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return nil, errors.New("invalid status")
|
return nil, errors.New("invalid status")
|
||||||
}
|
}
|
||||||
if _, err := s.validateStatus(ctx, spaceID, statusID); err != nil {
|
if _, err := s.validateStatus(ctx, task.TaskListID, statusID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
adjacent, err := s.isAdjacentStatusMove(ctx, spaceID, task.StatusID, statusID)
|
adjacent, err := s.isAdjacentStatusMove(ctx, task.TaskListID, task.StatusID, statusID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -582,7 +589,7 @@ func (s *TaskService) TransitionTaskStatus(ctx context.Context, spaceID, taskID,
|
|||||||
return nil, errors.New("task not found")
|
return nil, errors.New("task not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
statuses, err := s.taskStatusRepo.ListStatuses(ctx, task.TaskListID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -812,25 +819,28 @@ func (s *TaskService) DeleteTaskList(ctx context.Context, spaceID, taskListID, u
|
|||||||
return errors.New("task list not found")
|
return errors.New("task list not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"task_list_id": taskListID})
|
if err := s.taskRepo.DeleteTasksByTaskListID(ctx, taskListID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(tasks) > 0 {
|
|
||||||
return errors.New("cannot delete task list with tasks")
|
if err := s.taskStatusRepo.DeleteStatusesByTaskListID(ctx, taskListID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.taskListRepo.DeleteTaskList(ctx, taskListID)
|
return s.taskListRepo.DeleteTaskList(ctx, taskListID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
|
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
|
||||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -841,7 +851,7 @@ func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.Obj
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
|
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
|
||||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -852,13 +862,16 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
|
|||||||
if !hasPermission {
|
if !hasPermission {
|
||||||
return nil, errors.New("insufficient permissions")
|
return nil, errors.New("insufficient permissions")
|
||||||
}
|
}
|
||||||
|
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
status := &entities.TaskStatus{
|
status := &entities.TaskStatus{
|
||||||
SpaceID: spaceID,
|
TaskListID: taskListID,
|
||||||
Name: strings.TrimSpace(req.Name),
|
Name: strings.TrimSpace(req.Name),
|
||||||
Color: strings.TrimSpace(req.Color),
|
Color: strings.TrimSpace(req.Color),
|
||||||
Order: len(statuses),
|
Order: len(statuses),
|
||||||
@@ -872,7 +885,7 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
|
|||||||
return dto.NewTaskStatusDTO(status), nil
|
return dto.NewTaskStatusDTO(status), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
|
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
|
||||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -885,7 +898,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
|
|||||||
}
|
}
|
||||||
|
|
||||||
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
|
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
|
||||||
if err != nil || status.SpaceID != spaceID {
|
if err != nil || status.TaskListID != taskListID {
|
||||||
return nil, errors.New("task status not found")
|
return nil, errors.New("task status not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,7 +913,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
|
|||||||
return dto.NewTaskStatusDTO(status), nil
|
return dto.NewTaskStatusDTO(status), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID) error {
|
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID) error {
|
||||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -912,7 +925,7 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
|
|||||||
return errors.New("insufficient permissions")
|
return errors.New("insufficient permissions")
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -932,10 +945,10 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.normalizeStatusOrder(ctx, spaceID)
|
return s.normalizeStatusOrder(ctx, taskListID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
|
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
|
||||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -946,8 +959,11 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
|||||||
if !hasPermission {
|
if !hasPermission {
|
||||||
return nil, errors.New("insufficient permissions")
|
return nil, errors.New("insufficient permissions")
|
||||||
}
|
}
|
||||||
|
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -969,7 +985,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
|||||||
}
|
}
|
||||||
status := statusByID[statusID]
|
status := statusByID[statusID]
|
||||||
if status == nil {
|
if status == nil {
|
||||||
return nil, errors.New("status id does not belong to this space")
|
return nil, errors.New("status id does not belong to this task list")
|
||||||
}
|
}
|
||||||
if _, exists := seen[statusID]; exists {
|
if _, exists := seen[statusID]; exists {
|
||||||
return nil, errors.New("duplicate status id in ordered_status_ids")
|
return nil, errors.New("duplicate status id in ordered_status_ids")
|
||||||
@@ -1000,7 +1016,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1012,8 +1028,8 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskService) normalizeStatusOrder(ctx context.Context, spaceID bson.ObjectID) error {
|
func (s *TaskService) normalizeStatusOrder(ctx context.Context, taskListID bson.ObjectID) error {
|
||||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ type Task struct {
|
|||||||
UpdatedAt time.Time `bson:"updated_at"`
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskStatus defines the ordered linear status progression for a space.
|
// TaskStatus defines the ordered linear status progression for a task list.
|
||||||
type TaskStatus struct {
|
type TaskStatus struct {
|
||||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
SpaceID bson.ObjectID `bson:"space_id"`
|
TaskListID bson.ObjectID `bson:"task_list_id"`
|
||||||
Name string `bson:"name"`
|
Name string `bson:"name"`
|
||||||
Color string `bson:"color,omitempty"`
|
Color string `bson:"color,omitempty"`
|
||||||
Order int `bson:"order"`
|
Order int `bson:"order"`
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ type TaskRepository interface {
|
|||||||
SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error)
|
SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error)
|
||||||
UpdateTask(ctx context.Context, task *entities.Task) error
|
UpdateTask(ctx context.Context, task *entities.Task) error
|
||||||
DeleteTask(ctx context.Context, id bson.ObjectID) error
|
DeleteTask(ctx context.Context, id bson.ObjectID) error
|
||||||
|
DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
|
||||||
DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||||
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
|
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
|
||||||
}
|
}
|
||||||
@@ -244,7 +245,8 @@ type TaskListRepository interface {
|
|||||||
type TaskStatusRepository interface {
|
type TaskStatusRepository interface {
|
||||||
CreateStatus(ctx context.Context, status *entities.TaskStatus) error
|
CreateStatus(ctx context.Context, status *entities.TaskStatus) error
|
||||||
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
|
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
|
||||||
ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error)
|
ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error)
|
||||||
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
|
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
|
||||||
DeleteStatus(ctx context.Context, id bson.ObjectID) error
|
DeleteStatus(ctx context.Context, id bson.ObjectID) error
|
||||||
|
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ func (r *TaskRepository) DeleteTask(ctx context.Context, id bson.ObjectID) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
|
func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
|
||||||
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
|
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
|
||||||
return err
|
return err
|
||||||
@@ -227,8 +232,8 @@ func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.Object
|
|||||||
return &status, nil
|
return &status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) {
|
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, taskListID 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}}))
|
cursor, err := r.collection.Find(ctx, bson.M{"task_list_id": taskListID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -252,14 +257,19 @@ func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectI
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *TaskStatusRepository) DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
|
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||||
{
|
{
|
||||||
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}},
|
Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "name", Value: 1}},
|
||||||
Options: options.Index().SetUnique(true),
|
Options: options.Index().SetUnique(true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "order", Value: 1}},
|
Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "order", Value: 1}},
|
||||||
Options: options.Index().SetUnique(true),
|
Options: options.Index().SetUnique(true),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -380,8 +380,13 @@ func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, userID)
|
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, taskListID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -397,6 +402,11 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req dto.CreateTaskStatusRequest
|
var req dto.CreateTaskStatusRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -404,7 +414,7 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
status, err := h.taskService.CreateStatus(r.Context(), spaceID, userID, &req)
|
status, err := h.taskService.CreateStatus(r.Context(), spaceID, taskListID, userID, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -421,6 +431,11 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
|
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "invalid status id", http.StatusBadRequest)
|
http.Error(w, "invalid status id", http.StatusBadRequest)
|
||||||
@@ -433,7 +448,7 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, statusID, userID, &req)
|
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, taskListID, statusID, userID, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -449,13 +464,18 @@ func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
|
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "invalid status id", http.StatusBadRequest)
|
http.Error(w, "invalid status id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.taskService.DeleteStatus(r.Context(), spaceID, statusID, userID); err != nil {
|
if err := h.taskService.DeleteStatus(r.Context(), spaceID, taskListID, statusID, userID); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -468,6 +488,11 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req dto.ReorderTaskStatusesRequest
|
var req dto.ReorderTaskStatusesRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -475,7 +500,7 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, userID, req.OrderedStatusIDs)
|
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, taskListID, userID, req.OrderedStatusIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ FROM node:25-alpine AS frontend-builder
|
|||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
ARG VITE_API_BASE_URL
|
|
||||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
|
||||||
|
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./devops/docker/Dockerfile
|
dockerfile: ./devops/docker/Dockerfile
|
||||||
args:
|
|
||||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
|
||||||
container_name: notely-app
|
container_name: notely-app
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT}:${BACKEND_PORT}"
|
- "${BACKEND_PORT}:${BACKEND_PORT}"
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
# Frontend Environment Example
|
# Frontend Environment Example
|
||||||
|
|
||||||
# API Base URL (Backend server)
|
|
||||||
VITE_API_BASE_URL=http://localhost:8080
|
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
VITE_ENV=development
|
VITE_ENV=development
|
||||||
|
|
||||||
|
|||||||
1447
frontend/src/App.vue
1447
frontend/src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -66,24 +66,6 @@
|
|||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-zone {
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid #f3b5b5;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: var(--color-surface)5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-zone-title {
|
|
||||||
color: #9f1c1c;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-zone-copy {
|
|
||||||
color: #7a2727;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-mention-panel {
|
.task-mention-panel {
|
||||||
margin-top: 0.45rem;
|
margin-top: 0.45rem;
|
||||||
border: 1px solid #dbe4f0;
|
border: 1px solid #dbe4f0;
|
||||||
@@ -225,19 +207,6 @@
|
|||||||
background-color: var(--color-surface);
|
background-color: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .danger-zone {
|
|
||||||
background: #2d1a1a;
|
|
||||||
border-color: #7a3030;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .danger-zone-title {
|
|
||||||
color: #fc8181;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .danger-zone-copy {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .task-mention-panel {
|
:root[data-bs-theme="dark"] .task-mention-panel {
|
||||||
border-color: #3a4558;
|
border-color: #3a4558;
|
||||||
background: #1f2733;
|
background: #1f2733;
|
||||||
@@ -296,5 +265,3 @@
|
|||||||
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
|
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%);
|
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -289,24 +289,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-zone {
|
|
||||||
border: 1px solid #f3b5b5;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: var(--color-surface) 5f5;
|
|
||||||
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) {
|
@media (max-width: 900px) {
|
||||||
.task-filters {
|
.task-filters {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
32
frontend/src/assets/styles/shared/danger-zone.css
Normal file
32
frontend/src/assets/styles/shared/danger-zone.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.danger-zone {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #f3b5b5;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone-title {
|
||||||
|
color: #9f1c1c;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone-copy {
|
||||||
|
color: #7a2727;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .danger-zone {
|
||||||
|
background: #2d1a1a;
|
||||||
|
border-color: #7a3030;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .danger-zone-title {
|
||||||
|
color: #fc8181;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .danger-zone-copy {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
@@ -62,17 +62,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mode === 'edit'" class="col-12">
|
<div v-if="mode === 'edit'" class="col-12">
|
||||||
<div class="danger-zone border border-danger-subtle rounded p-3 mt-2">
|
<DangerZonePanel
|
||||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
|
class="mt-4"
|
||||||
<div>
|
title-id="danger-zone-title"
|
||||||
<div class="fw-semibold text-danger">Danger Zone</div>
|
title="Danger Zone"
|
||||||
<div class="small text-muted">Permanently delete this provider configuration.</div>
|
description="Permanently delete this provider configuration. This action cannot be undone."
|
||||||
</div>
|
>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
|
<button class="btn btn-danger" type="button" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
|
||||||
<i class="mdi mdi-trash-can-outline me-1" aria-hidden="true"></i>Delete Provider
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
|
Delete Provider
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</DangerZonePanel>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
mode: {
|
mode: {
|
||||||
@@ -179,6 +180,3 @@ const handleSubmit = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>
|
<style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<teleport to="body">
|
<teleport v-if="!showDeleteConfirmModal" to="body">
|
||||||
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
|
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -85,24 +85,39 @@
|
|||||||
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
|
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<div class="border border-danger rounded p-3 mt-3">
|
<DangerZonePanel
|
||||||
<h6 class="text-danger mb-1">Danger Zone</h6>
|
class="mt-4"
|
||||||
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
|
title-id="danger-zone-title"
|
||||||
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace">
|
title="Danger Zone"
|
||||||
|
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
|
||||||
|
>
|
||||||
|
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
|
||||||
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
{{ deleting ? "Deleting..." : "Delete Space" }}
|
{{ deleting ? "Deleting..." : "Delete Space" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</DangerZonePanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||||
</teleport>
|
</teleport>
|
||||||
|
|
||||||
|
<ConfirmActionModal
|
||||||
|
:visible="showDeleteConfirmModal"
|
||||||
|
:title="deleteConfirmTitle"
|
||||||
|
:message="deleteConfirmMessage"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@close="closeDeleteConfirmModal"
|
||||||
|
@confirm="confirmDeleteAction"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import apiClient from "../services/apiClient";
|
import apiClient from "../services/apiClient";
|
||||||
|
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||||
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
space: {
|
space: {
|
||||||
@@ -133,6 +148,20 @@ const error = ref("");
|
|||||||
const success = ref("");
|
const success = ref("");
|
||||||
const newMember = ref({ user_id: "" });
|
const newMember = ref({ user_id: "" });
|
||||||
const deleting = ref(false);
|
const deleting = ref(false);
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
|
const deleteConfirmIntent = ref({
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
|
||||||
|
const deleteConfirmMessage = computed(() => {
|
||||||
|
if (deleteConfirmIntent.value.type === "member") {
|
||||||
|
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
|
||||||
|
return `Remove member "${memberName}" from this space?`;
|
||||||
|
}
|
||||||
|
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
|
||||||
|
});
|
||||||
|
|
||||||
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
|
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
|
||||||
|
|
||||||
@@ -208,9 +237,20 @@ const addMember = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMember = async (member) => {
|
const removeMember = (member) => {
|
||||||
const memberName = member?.username || member?.user_id;
|
if (!member?.user_id) {
|
||||||
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "member",
|
||||||
|
payload: member,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMemberConfirmed = async (member) => {
|
||||||
|
if (!member?.user_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,10 +276,15 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteSpace = async () => {
|
const requestDeleteSpace = () => {
|
||||||
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) {
|
deleteConfirmIntent.value = {
|
||||||
return;
|
type: "space",
|
||||||
}
|
payload: props.space,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSpaceConfirmed = async () => {
|
||||||
deleting.value = true;
|
deleting.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
try {
|
try {
|
||||||
@@ -247,13 +292,51 @@ const deleteSpace = async () => {
|
|||||||
emit("deleted", props.space);
|
emit("deleted", props.space);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to delete space.";
|
error.value = e.response?.data || "Failed to delete space.";
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false;
|
deleting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeDeleteConfirmModal = () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteAction = async () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, payload } = deleteConfirmIntent.value;
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
if (type === "member") {
|
||||||
|
await removeMemberConfirmed(payload);
|
||||||
|
} else if (type === "space") {
|
||||||
|
await deleteSpaceConfirmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>
|
<style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
62
frontend/src/components/ConfirmActionModal.vue
Normal file
62
frontend/src/components/ConfirmActionModal.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div v-if="visible" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title d-flex align-items-center gap-2 mb-0">
|
||||||
|
<i class="mdi mdi-alert-outline text-danger" aria-hidden="true"></i>
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" :disabled="busy" @click="emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted mb-0">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" :disabled="busy" @click="emit('close')">{{ cancelLabel }}</button>
|
||||||
|
<button type="button" class="btn btn-danger" :disabled="busy" @click="emit('confirm')">
|
||||||
|
{{ busy ? busyLabel : confirmLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="visible" class="modal-backdrop fade show"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "Confirm Deletion",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: "Are you sure you want to continue?",
|
||||||
|
},
|
||||||
|
confirmLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "Delete",
|
||||||
|
},
|
||||||
|
cancelLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "Cancel",
|
||||||
|
},
|
||||||
|
busyLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "Deleting...",
|
||||||
|
},
|
||||||
|
busy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "confirm"]);
|
||||||
|
</script>
|
||||||
24
frontend/src/components/DangerZonePanel.vue
Normal file
24
frontend/src/components/DangerZonePanel.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<section class="danger-zone" :aria-labelledby="titleId">
|
||||||
|
<h3 :id="titleId" class="danger-zone-title mb-2">{{ title }}</h3>
|
||||||
|
<p class="danger-zone-copy mb-3">{{ description }}</p>
|
||||||
|
<slot></slot>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
titleId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "Danger Zone",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: "This action is permanent and cannot be undone.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
317
frontend/src/components/EditTaskListModal.vue
Normal file
317
frontend/src/components/EditTaskListModal.vue
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<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-lg modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Edit Task List</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Task List Details -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="editTaskListName">Name</label>
|
||||||
|
<input id="editTaskListName" v-model="listForm.name" type="text" class="form-control" maxlength="120" />
|
||||||
|
|
||||||
|
<label class="form-label mt-3" for="editTaskListCategory">Category</label>
|
||||||
|
<select id="editTaskListCategory" v-model="listForm.category_id" class="form-select">
|
||||||
|
<option :value="null">No category</option>
|
||||||
|
<option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">{{ cat.label }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-primary mt-3" @click="saveListDetails">Save Details</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<!-- Status Progression -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<!-- Danger Zone -->
|
||||||
|
<DangerZonePanel
|
||||||
|
v-if="canDeleteTaskList"
|
||||||
|
title-id="edit-task-list-danger-zone"
|
||||||
|
title="Danger Zone"
|
||||||
|
description="Delete this task list, all associated tasks, and statuses permanently."
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-danger" @click="emit('delete-task-list', taskList)">
|
||||||
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
|
Delete Task List
|
||||||
|
</button>
|
||||||
|
</DangerZonePanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
|
||||||
|
<!-- Status Create/Edit Sub-Modal -->
|
||||||
|
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" style="z-index: 1060" @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="editStatusName">Status Name</label>
|
||||||
|
<input id="editStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
|
||||||
|
|
||||||
|
<label class="form-label mt-3" for="editStatusColor">Status Color</label>
|
||||||
|
<div class="status-color-row">
|
||||||
|
<input id="editStatusColor" 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>
|
||||||
|
|
||||||
|
<DangerZonePanel
|
||||||
|
v-if="statusMode === 'edit'"
|
||||||
|
class="mt-4"
|
||||||
|
title-id="edit-status-danger-zone"
|
||||||
|
title="Danger Zone"
|
||||||
|
description="Deleting this status is permanent and cannot be undone."
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
|
||||||
|
</DangerZonePanel>
|
||||||
|
</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" style="z-index: 1055"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
taskList: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
categoryOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
canDeleteTaskList: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "update-task-list", "reorder-status", "create-status", "rename-status", "delete-status", "delete-task-list"]);
|
||||||
|
|
||||||
|
const listForm = ref({ name: "", category_id: null });
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.taskList,
|
||||||
|
(tl) => {
|
||||||
|
listForm.value = {
|
||||||
|
name: tl?.name || "",
|
||||||
|
category_id: tl?.category_id || null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveListDetails = () => {
|
||||||
|
const name = listForm.value.name?.trim();
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("update-task-list", {
|
||||||
|
name,
|
||||||
|
category_id: listForm.value.category_id || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status drag-and-drop reorder
|
||||||
|
const draggedStatusId = ref("");
|
||||||
|
const dragOverStatusId = ref("");
|
||||||
|
|
||||||
|
const onStatusDragStart = (id) => {
|
||||||
|
draggedStatusId.value = id;
|
||||||
|
};
|
||||||
|
const onStatusDragOver = (id) => {
|
||||||
|
dragOverStatusId.value = id;
|
||||||
|
};
|
||||||
|
const onStatusDragLeave = (id) => {
|
||||||
|
if (dragOverStatusId.value === id) {
|
||||||
|
dragOverStatusId.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onStatusDrop = (targetId) => {
|
||||||
|
if (!draggedStatusId.value || draggedStatusId.value === targetId) {
|
||||||
|
onStatusDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ordered = props.statuses.map((s) => s.id);
|
||||||
|
const fromIndex = ordered.indexOf(draggedStatusId.value);
|
||||||
|
const targetIndex = ordered.indexOf(targetId);
|
||||||
|
if (fromIndex < 0 || targetIndex < 0) {
|
||||||
|
onStatusDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ordered.splice(fromIndex, 1);
|
||||||
|
const insertIndex = ordered.indexOf(targetId);
|
||||||
|
ordered.splice(insertIndex, 0, draggedStatusId.value);
|
||||||
|
emit("reorder-status", ordered);
|
||||||
|
onStatusDragEnd();
|
||||||
|
};
|
||||||
|
const onStatusDragEnd = () => {
|
||||||
|
draggedStatusId.value = "";
|
||||||
|
dragOverStatusId.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status create/edit modal
|
||||||
|
const showStatusModal = ref(false);
|
||||||
|
const statusMode = ref("create");
|
||||||
|
const editingStatusId = ref("");
|
||||||
|
const statusForm = ref({ 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 closeStatusModal = () => {
|
||||||
|
showStatusModal.value = false;
|
||||||
|
statusMode.value = "create";
|
||||||
|
editingStatusId.value = "";
|
||||||
|
statusForm.value = { name: "", color: "#7c8596" };
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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>
|
||||||
|
.status-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item.is-drag-over {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-color-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-color-row .form-control-color {
|
||||||
|
width: 40px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
|
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
|
||||||
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
|
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
|
||||||
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
|
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
|
||||||
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="deleteItem(obj)">
|
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="requestDeleteItem(obj)">
|
||||||
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
|
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,11 +87,14 @@
|
|||||||
<!-- Hidden file input -->
|
<!-- Hidden file input -->
|
||||||
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
|
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmActionModal :visible="showDeleteConfirmModal" title="Delete Item" :message="deleteConfirmMessage" :busy="deletingItem" @close="closeDeleteConfirmModal" @confirm="confirmDeleteItem" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick } from "vue";
|
import { ref, computed, watch, nextTick } from "vue";
|
||||||
import apiClient from "../services/apiClient";
|
import apiClient from "../services/apiClient";
|
||||||
|
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
spaceId: {
|
spaceId: {
|
||||||
@@ -117,6 +120,9 @@ const showNewFolderInput = ref(false);
|
|||||||
const newFolderName = ref("");
|
const newFolderName = ref("");
|
||||||
const fileInputRef = ref(null);
|
const fileInputRef = ref(null);
|
||||||
const newFolderInputRef = ref(null);
|
const newFolderInputRef = ref(null);
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
const pendingDeleteItem = ref(null);
|
||||||
|
const deletingItem = ref(false);
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
if (!currentPrefix.value) return [];
|
if (!currentPrefix.value) return [];
|
||||||
@@ -215,9 +221,37 @@ const createFolder = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteItem = async (obj) => {
|
const requestDeleteItem = (obj) => {
|
||||||
const label = displayName(obj);
|
if (!obj) {
|
||||||
if (!confirm(`Delete "${label}"?${obj.is_folder ? "./nThis will delete all files inside the folder." : ""}`)) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDeleteItem.value = obj;
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeleteConfirmModal = () => {
|
||||||
|
if (deletingItem.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
pendingDeleteItem.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteConfirmMessage = computed(() => {
|
||||||
|
const obj = pendingDeleteItem.value;
|
||||||
|
const label = obj ? displayName(obj) : "this item";
|
||||||
|
return obj?.is_folder ? `Delete "${label}"? This will delete all files inside the folder.` : `Delete "${label}"?`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmDeleteItem = async () => {
|
||||||
|
const obj = pendingDeleteItem.value;
|
||||||
|
if (!obj) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingItem.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
if (obj.is_folder) {
|
if (obj.is_folder) {
|
||||||
@@ -227,8 +261,12 @@ const deleteItem = async (obj) => {
|
|||||||
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
|
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
|
||||||
}
|
}
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
pendingDeleteItem.value = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Delete failed";
|
error.value = e.response?.data || "Delete failed";
|
||||||
|
} finally {
|
||||||
|
deletingItem.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -125,16 +125,22 @@
|
|||||||
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title">
|
<DangerZonePanel v-if="canDelete && editingNote.id" class="mt-4" title-id="danger-zone-title" title="Danger Zone" description="Deleting this note is permanent and cannot be undone.">
|
||||||
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3>
|
<button class="btn btn-danger" type="button" @click="requestDelete">
|
||||||
<p class="danger-zone-copy mb-3">Deleting this note is permanent and cannot be undone.</p>
|
|
||||||
<button class="btn btn-danger" type="button" @click="confirmDelete">
|
|
||||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
Delete Note
|
Delete Note
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</DangerZonePanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmActionModal
|
||||||
|
:visible="showDeleteConfirmModal"
|
||||||
|
title="Delete Note"
|
||||||
|
message="Are you sure you want to delete this note? This action cannot be undone."
|
||||||
|
@close="showDeleteConfirmModal = false"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -144,6 +150,8 @@ import { useSettingsStore } from "../stores/settingsStore";
|
|||||||
import { useSpaceStore } from "../stores/spaceStore";
|
import { useSpaceStore } from "../stores/spaceStore";
|
||||||
import { renderMarkdown } from "../utils/markdown.js";
|
import { renderMarkdown } from "../utils/markdown.js";
|
||||||
import FileExplorer from "./FileExplorer.vue";
|
import FileExplorer from "./FileExplorer.vue";
|
||||||
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||||
|
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
note: {
|
note: {
|
||||||
@@ -187,6 +195,7 @@ const linkedTasks = ref([]);
|
|||||||
const showTaskPicker = ref(false);
|
const showTaskPicker = ref(false);
|
||||||
const taskPickerQuery = ref("");
|
const taskPickerQuery = ref("");
|
||||||
const taskPickerLoading = ref(false);
|
const taskPickerLoading = ref(false);
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
|
||||||
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
|
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
|
||||||
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
|
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
|
||||||
@@ -374,13 +383,22 @@ const autoSave = () => {
|
|||||||
detectTaskMention();
|
detectTaskMention();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestDelete = () => {
|
||||||
|
if (!props.canDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (!props.canDelete) {
|
if (!props.canDelete) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (confirm("Are you sure you want to delete this note?")) {
|
if (!editingNote.value?.id) {
|
||||||
emit("delete", editingNote.value.id);
|
return;
|
||||||
}
|
}
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
emit("delete", editingNote.value.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Insert markdown snippet at the textarea cursor position. */
|
/** Insert markdown snippet at the textarea cursor position. */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<teleport to="body">
|
<teleport v-if="!showDeleteConfirmModal" to="body">
|
||||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -76,13 +76,17 @@
|
|||||||
|
|
||||||
<template v-if="canDeleteSpace">
|
<template v-if="canDeleteSpace">
|
||||||
<hr />
|
<hr />
|
||||||
<div class="border border-danger rounded p-3 mt-3">
|
<DangerZonePanel
|
||||||
<h6 class="text-danger mb-1">Danger Zone</h6>
|
class="mt-4"
|
||||||
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
|
title-id="danger-zone-title"
|
||||||
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace">
|
title="Danger Zone"
|
||||||
|
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
|
||||||
|
>
|
||||||
|
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
|
||||||
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
{{ deleting ? "Deleting..." : "Delete Space" }}
|
{{ deleting ? "Deleting..." : "Delete Space" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</DangerZonePanel>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
|
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
|
||||||
@@ -93,12 +97,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop fade show"></div>
|
<div class="modal-backdrop fade show"></div>
|
||||||
</teleport>
|
</teleport>
|
||||||
|
|
||||||
|
<ConfirmActionModal
|
||||||
|
:visible="showDeleteConfirmModal"
|
||||||
|
:title="deleteConfirmTitle"
|
||||||
|
:message="deleteConfirmMessage"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@close="closeDeleteConfirmModal"
|
||||||
|
@confirm="confirmDeleteAction"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import apiClient from "../services/apiClient";
|
import apiClient from "../services/apiClient";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||||
|
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
space: {
|
space: {
|
||||||
@@ -130,6 +145,20 @@ const success = ref("");
|
|||||||
const memberForm = ref({ user_id: "" });
|
const memberForm = ref({ user_id: "" });
|
||||||
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
|
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
|
||||||
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
|
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
|
const deleteConfirmIntent = ref({
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
|
||||||
|
const deleteConfirmMessage = computed(() => {
|
||||||
|
if (deleteConfirmIntent.value.type === "member") {
|
||||||
|
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
|
||||||
|
return `Remove member "${memberName}" from this space?`;
|
||||||
|
}
|
||||||
|
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.space,
|
() => props.space,
|
||||||
@@ -224,13 +253,24 @@ const addMember = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMember = async (member) => {
|
const removeMember = (member) => {
|
||||||
if (!canManageMembers.value) {
|
if (!canManageMembers.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberName = member?.username || member?.user_id;
|
if (!member?.user_id) {
|
||||||
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "member",
|
||||||
|
payload: member,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMemberConfirmed = async (member) => {
|
||||||
|
if (!member?.user_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,10 +291,15 @@ if (canViewMembers.value) {
|
|||||||
Promise.all([loadMembers(), loadUserOptions()]);
|
Promise.all([loadMembers(), loadUserOptions()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteSpace = async () => {
|
const requestDeleteSpace = () => {
|
||||||
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) {
|
deleteConfirmIntent.value = {
|
||||||
return;
|
type: "space",
|
||||||
}
|
payload: props.space,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSpaceConfirmed = async () => {
|
||||||
deleting.value = true;
|
deleting.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
try {
|
try {
|
||||||
@@ -262,8 +307,49 @@ const deleteSpace = async () => {
|
|||||||
emit("deleted", props.space);
|
emit("deleted", props.space);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to delete space.";
|
error.value = e.response?.data || "Failed to delete space.";
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false;
|
deleting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeDeleteConfirmModal = () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteAction = async () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, payload } = deleteConfirmIntent.value;
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
if (type === "member") {
|
||||||
|
await removeMemberConfirmed(payload);
|
||||||
|
} else if (type === "space") {
|
||||||
|
await deleteSpaceConfirmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<h4 class="mb-0">Tasks</h4>
|
<h4 class="mb-0">Tasks</h4>
|
||||||
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
|
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button v-if="selectedTaskList" class="btn btn-sm btn-outline-secondary" @click="emit('edit-task-list')"><i class="mdi mdi-cog-outline me-1" aria-hidden="true"></i>Edit Task List</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="task-filters">
|
<div class="task-filters">
|
||||||
@@ -23,36 +24,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 class="task-status-groups">
|
||||||
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
|
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
|
||||||
|
|
||||||
@@ -184,42 +155,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -235,23 +170,18 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
selectedTaskList: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status"]);
|
const emit = defineEmits(["create-task", "select-task", "filter-change", "update-task-status", "edit-task-list"]);
|
||||||
|
|
||||||
const filterStatus = ref("");
|
const filterStatus = ref("");
|
||||||
const filterParent = 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 expandedTaskIds = ref({});
|
||||||
const openStatusMenuTaskId = ref("");
|
const openStatusMenuTaskId = ref("");
|
||||||
const statusForm = ref({
|
|
||||||
name: "",
|
|
||||||
color: "#7c8596",
|
|
||||||
});
|
|
||||||
|
|
||||||
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
|
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
|
||||||
const tasksById = computed(() => {
|
const tasksById = computed(() => {
|
||||||
@@ -363,113 +293,6 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener("click", onDocumentClick);
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>
|
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>
|
||||||
|
|||||||
145
frontend/src/components/app/AppModalHost.vue
Normal file
145
frontend/src/components/app/AppModalHost.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<CreateSpaceModal v-if="showCreateSpaceModal" @close="emit('close-create-space')" @create="emit('create-space', $event)" />
|
||||||
|
<CreateCategoryModal
|
||||||
|
v-if="showCreateCategoryModal"
|
||||||
|
:category="editingCategory"
|
||||||
|
:parent-options="categoryParentOptions"
|
||||||
|
:parent-id="categoryModalParentId"
|
||||||
|
@close="emit('close-create-category')"
|
||||||
|
@submit="emit('submit-category', $event)"
|
||||||
|
/>
|
||||||
|
<CreateNoteModal
|
||||||
|
v-if="showCreateNoteModal"
|
||||||
|
:category-options="categoryOptions"
|
||||||
|
:default-category-id="selectedCategoryId"
|
||||||
|
@close="emit('close-create-note')"
|
||||||
|
@create="emit('create-note', $event)"
|
||||||
|
/>
|
||||||
|
<CreateTaskListModal
|
||||||
|
v-if="showCreateTaskListModal"
|
||||||
|
:category-options="categoryOptions"
|
||||||
|
:default-category-id="selectedCategoryId"
|
||||||
|
@close="emit('close-create-task-list')"
|
||||||
|
@create="emit('create-task-list', $event)"
|
||||||
|
/>
|
||||||
|
<SpaceSettingsModal
|
||||||
|
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
|
||||||
|
:space="currentSpace"
|
||||||
|
@close="emit('close-space-settings')"
|
||||||
|
@saved="emit('saved-space', $event)"
|
||||||
|
@deleted="emit('deleted-space', $event)"
|
||||||
|
/>
|
||||||
|
<TaskDetailModal
|
||||||
|
v-if="showTaskModal"
|
||||||
|
:task="taskModalDraft || {}"
|
||||||
|
:statuses="taskStatuses"
|
||||||
|
:parent-task-options="taskParentOptions"
|
||||||
|
:subtasks="taskDetailSubtasks"
|
||||||
|
@close="emit('close-task-modal')"
|
||||||
|
@save-task="emit('save-task', $event)"
|
||||||
|
@delete-task="emit('delete-task', $event)"
|
||||||
|
@transition="emit('transition-task', $event)"
|
||||||
|
@create-subtask="emit('create-subtask', $event)"
|
||||||
|
@open-task="emit('open-task', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import CreateSpaceModal from "../CreateSpaceModal.vue";
|
||||||
|
import CreateCategoryModal from "../CreateCategoryModal.vue";
|
||||||
|
import CreateNoteModal from "../CreateNoteModal.vue";
|
||||||
|
import CreateTaskListModal from "../CreateTaskListModal.vue";
|
||||||
|
import SpaceSettingsModal from "../SpaceSettingsModal.vue";
|
||||||
|
import TaskDetailModal from "../TaskDetailModal.vue";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
showCreateSpaceModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showCreateCategoryModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
editingCategory: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
categoryParentOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
categoryModalParentId: {
|
||||||
|
type: [String, Number, null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
showCreateNoteModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
categoryOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
selectedCategoryId: {
|
||||||
|
type: [String, Number, null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
showCreateTaskListModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showSpaceSettingsModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
currentSpace: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
canManageSpaceSettings: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showTaskModal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
taskModalDraft: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
taskStatuses: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
taskParentOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
taskDetailSubtasks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"close-create-space",
|
||||||
|
"create-space",
|
||||||
|
"close-create-category",
|
||||||
|
"submit-category",
|
||||||
|
"close-create-note",
|
||||||
|
"create-note",
|
||||||
|
"close-create-task-list",
|
||||||
|
"create-task-list",
|
||||||
|
"close-space-settings",
|
||||||
|
"saved-space",
|
||||||
|
"deleted-space",
|
||||||
|
"close-task-modal",
|
||||||
|
"save-task",
|
||||||
|
"delete-task",
|
||||||
|
"transition-task",
|
||||||
|
"create-subtask",
|
||||||
|
"open-task",
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
160
frontend/src/components/app/AppWorkspaceContent.vue
Normal file
160
frontend/src/components/app/AppWorkspaceContent.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div class="content p-4">
|
||||||
|
<TaskBoard
|
||||||
|
v-if="activeView === 'tasks'"
|
||||||
|
:tasks="tasks"
|
||||||
|
:statuses="taskStatuses"
|
||||||
|
:selected-task-list="selectedTaskList"
|
||||||
|
@select-task="emit('select-task', $event)"
|
||||||
|
@filter-change="emit('filter-change', $event)"
|
||||||
|
@update-task-status="emit('update-task-status', $event)"
|
||||||
|
@edit-task-list="emit('edit-task-list')"
|
||||||
|
/>
|
||||||
|
<SearchResultsPage
|
||||||
|
v-else-if="isSearchRoute"
|
||||||
|
:items="searchItems"
|
||||||
|
:query="searchQuery"
|
||||||
|
:current-page="searchPage"
|
||||||
|
:page-size="searchPageSize"
|
||||||
|
:view-mode="noteViewMode"
|
||||||
|
@select-note="emit('select-note', $event)"
|
||||||
|
@select-task-list="emit('select-task-list', $event)"
|
||||||
|
@page-change="emit('page-change', $event)"
|
||||||
|
/>
|
||||||
|
<NoteEditor
|
||||||
|
v-else-if="selectedNote && isEditingNote"
|
||||||
|
:note="selectedNote"
|
||||||
|
:category-options="categoryOptions"
|
||||||
|
:can-delete="canDeleteNotes"
|
||||||
|
:space-id="currentSpaceId"
|
||||||
|
@save="emit('save-note', $event)"
|
||||||
|
@delete="emit('delete-note', $event)"
|
||||||
|
@cancel="emit('cancel-edit-note')"
|
||||||
|
@open-linked-task="emit('open-linked-task', $event)"
|
||||||
|
/>
|
||||||
|
<NoteViewer
|
||||||
|
v-else-if="selectedNote"
|
||||||
|
:note="selectedNote"
|
||||||
|
:category-options="categoryOptions"
|
||||||
|
:space-id="currentSpaceId"
|
||||||
|
:linked-tasks="linkedTasksForSelectedNote"
|
||||||
|
@open-linked-task="emit('open-linked-task', $event)"
|
||||||
|
/>
|
||||||
|
<WorkspaceList
|
||||||
|
v-else
|
||||||
|
:items="displayedItems"
|
||||||
|
:can-load-more="canLoadMoreMainNotes"
|
||||||
|
:is-loading-more="isLoadingMoreMainNotes"
|
||||||
|
:view-mode="noteViewMode"
|
||||||
|
@select-note="emit('select-note', $event)"
|
||||||
|
@select-task-list="emit('select-task-list', $event)"
|
||||||
|
@load-more="emit('load-more')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import TaskBoard from "../TaskBoard.vue";
|
||||||
|
import SearchResultsPage from "../SearchResultsPage.vue";
|
||||||
|
import NoteEditor from "../NoteEditor.vue";
|
||||||
|
import NoteViewer from "../NoteViewer.vue";
|
||||||
|
import WorkspaceList from "../WorkspaceList.vue";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
activeView: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
taskStatuses: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
selectedTaskList: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
canDeleteTasks: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isSearchRoute: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
searchItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
searchQuery: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
searchPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
searchPageSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 12,
|
||||||
|
},
|
||||||
|
noteViewMode: {
|
||||||
|
type: String,
|
||||||
|
default: "grid",
|
||||||
|
},
|
||||||
|
selectedNote: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isEditingNote: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
categoryOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
canDeleteNotes: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
currentSpaceId: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
linkedTasksForSelectedNote: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
displayedItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
canLoadMoreMainNotes: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isLoadingMoreMainNotes: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"select-task",
|
||||||
|
"filter-change",
|
||||||
|
"update-task-status",
|
||||||
|
"edit-task-list",
|
||||||
|
"select-note",
|
||||||
|
"select-task-list",
|
||||||
|
"page-change",
|
||||||
|
"save-note",
|
||||||
|
"delete-note",
|
||||||
|
"cancel-edit-note",
|
||||||
|
"open-linked-task",
|
||||||
|
"load-more",
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
@@ -6,6 +6,7 @@ import "bootstrap/dist/css/bootstrap.min.css";
|
|||||||
import "@mdi/font/css/materialdesignicons.min.css";
|
import "@mdi/font/css/materialdesignicons.min.css";
|
||||||
import "highlight.js/styles/github-dark.min.css";
|
import "highlight.js/styles/github-dark.min.css";
|
||||||
import "./assets/styles/main.css";
|
import "./assets/styles/main.css";
|
||||||
|
import "./assets/styles/shared/danger-zone.css";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<div class="user-row-actions">
|
<div class="user-row-actions">
|
||||||
<div class="d-flex gap-2 user-actions-stack">
|
<div class="d-flex gap-2 user-actions-stack">
|
||||||
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
|
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" @click="deleteUser(u)">Delete</button>
|
<button class="btn btn-sm btn-outline-danger" @click="requestDeleteUser(u)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
|
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="deleteGroup(group)">Delete</button>
|
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="requestDeleteGroup(group)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +286,16 @@
|
|||||||
:deleting="deletingProviderModal"
|
:deleting="deletingProviderModal"
|
||||||
@close="closeProviderModal"
|
@close="closeProviderModal"
|
||||||
@submit="submitProviderModal"
|
@submit="submitProviderModal"
|
||||||
@delete="deleteProviderFromModal"
|
@delete="requestDeleteProvider"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmActionModal
|
||||||
|
:visible="showDeleteConfirmModal"
|
||||||
|
:title="deleteConfirmTitle"
|
||||||
|
:message="deleteConfirmMessage"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@close="closeDeleteConfirmModal"
|
||||||
|
@confirm="confirmDeleteAction"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -298,6 +307,7 @@ import AdminSpaceModal from "../components/AdminSpaceModal.vue";
|
|||||||
import AdminGroupModal from "../components/AdminGroupModal.vue";
|
import AdminGroupModal from "../components/AdminGroupModal.vue";
|
||||||
import AdminUserModal from "../components/AdminUserModal.vue";
|
import AdminUserModal from "../components/AdminUserModal.vue";
|
||||||
import AdminProviderModal from "../components/AdminProviderModal.vue";
|
import AdminProviderModal from "../components/AdminProviderModal.vue";
|
||||||
|
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const activeTab = ref("users");
|
const activeTab = ref("users");
|
||||||
@@ -344,6 +354,12 @@ const providerModalMode = ref("create");
|
|||||||
const selectedProvider = ref(null);
|
const selectedProvider = ref(null);
|
||||||
const submittingProviderModal = ref(false);
|
const submittingProviderModal = ref(false);
|
||||||
const deletingProviderModal = ref(false);
|
const deletingProviderModal = ref(false);
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
|
const deleteConfirmIntent = ref({
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
|
||||||
const loadingFeatureFlags = ref(false);
|
const loadingFeatureFlags = ref(false);
|
||||||
const savingFeatureFlags = ref(false);
|
const savingFeatureFlags = ref(false);
|
||||||
@@ -365,6 +381,47 @@ const clearMessages = () => {
|
|||||||
successMessage.value = "";
|
successMessage.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteConfirmTitle = computed(() => {
|
||||||
|
if (deleteConfirmIntent.value.type === "user") {
|
||||||
|
return "Delete User";
|
||||||
|
}
|
||||||
|
if (deleteConfirmIntent.value.type === "group") {
|
||||||
|
return "Delete Group";
|
||||||
|
}
|
||||||
|
if (deleteConfirmIntent.value.type === "provider") {
|
||||||
|
return "Delete Identity Provider";
|
||||||
|
}
|
||||||
|
return "Confirm Deletion";
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteConfirmMessage = computed(() => {
|
||||||
|
if (deleteConfirmIntent.value.type === "user") {
|
||||||
|
const username = deleteConfirmIntent.value.payload?.username || "this user";
|
||||||
|
return `Delete user "${username}"? This action cannot be undone.`;
|
||||||
|
}
|
||||||
|
if (deleteConfirmIntent.value.type === "group") {
|
||||||
|
const name = deleteConfirmIntent.value.payload?.name || "this group";
|
||||||
|
return `Delete group "${name}"? This action cannot be undone.`;
|
||||||
|
}
|
||||||
|
if (deleteConfirmIntent.value.type === "provider") {
|
||||||
|
const name = deleteConfirmIntent.value.payload?.name || "this identity provider";
|
||||||
|
return `Delete identity provider "${name}"? This action cannot be undone.`;
|
||||||
|
}
|
||||||
|
return "Are you sure you want to continue?";
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeDeleteConfirmModal = () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (iso) => {
|
const formatDate = (iso) => {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
return new Date(iso).toLocaleDateString();
|
return new Date(iso).toLocaleDateString();
|
||||||
@@ -438,8 +495,20 @@ const submitUserModal = async ({ group_ids }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestDeleteUser = (user) => {
|
||||||
|
if (!user?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "user",
|
||||||
|
payload: user,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const deleteUser = async (user) => {
|
const deleteUser = async (user) => {
|
||||||
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) {
|
if (!user?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +599,7 @@ const deleteGroup = async (group) => {
|
|||||||
if (group.is_system) {
|
if (group.is_system) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) {
|
if (!group?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,9 +610,22 @@ const deleteGroup = async (group) => {
|
|||||||
await Promise.all([loadGroups(), loadUsers()]);
|
await Promise.all([loadGroups(), loadUsers()]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to delete group.";
|
error.value = e.response?.data || "Failed to delete group.";
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestDeleteGroup = (group) => {
|
||||||
|
if (!group?.id || group.is_system) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "group",
|
||||||
|
payload: group,
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const loadSpaces = async () => {
|
const loadSpaces = async () => {
|
||||||
loadingSpaces.value = true;
|
loadingSpaces.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
@@ -631,12 +713,21 @@ const loadProviders = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteProviderFromModal = async (provider) => {
|
const requestDeleteProvider = (provider) => {
|
||||||
if (!provider?.id) {
|
if (!provider?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
|
closeProviderModal();
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "provider",
|
||||||
|
payload: { ...provider },
|
||||||
|
};
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProviderFromModal = async (provider) => {
|
||||||
|
if (!provider?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,11 +740,42 @@ const deleteProviderFromModal = async (provider) => {
|
|||||||
closeProviderModal();
|
closeProviderModal();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to delete provider.";
|
error.value = e.response?.data || "Failed to delete provider.";
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
deletingProviderModal.value = false;
|
deletingProviderModal.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmDeleteAction = async () => {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, payload } = deleteConfirmIntent.value;
|
||||||
|
if (!type || !payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
if (type === "user") {
|
||||||
|
await deleteUser(payload);
|
||||||
|
} else if (type === "group") {
|
||||||
|
await deleteGroup(payload);
|
||||||
|
} else if (type === "provider") {
|
||||||
|
await deleteProviderFromModal(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
deleteConfirmIntent.value = {
|
||||||
|
type: "",
|
||||||
|
payload: null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadFeatureFlags = async () => {
|
const loadFeatureFlags = async () => {
|
||||||
loadingFeatureFlags.value = true;
|
loadingFeatureFlags.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
|
|||||||
1910
frontend/src/pages/Dashboard.vue
Normal file
1910
frontend/src/pages/Dashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="home-page">
|
|
||||||
<router-view />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup></script>
|
|
||||||
@@ -18,13 +18,25 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
name: "Home",
|
name: "Home",
|
||||||
component: () => import("../pages/Home.vue"),
|
component: () => import("../pages/Dashboard.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard/s/:spaceId/n/:noteId?",
|
||||||
|
name: "DashboardNote",
|
||||||
|
component: () => import("../pages/Dashboard.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard/s/:spaceId/t/:taskListId",
|
||||||
|
name: "DashboardTaskList",
|
||||||
|
component: () => import("../pages/Dashboard.vue"),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/search",
|
path: "/search",
|
||||||
name: "Search",
|
name: "Search",
|
||||||
component: () => import("../pages/Home.vue"),
|
component: () => import("../pages/Dashboard.vue"),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
|
||||||
|
const runtimeOrigin = typeof window !== "undefined" ? window.location.origin : "";
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080",
|
baseURL: runtimeOrigin,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const useSpaceStore = defineStore("space", () => {
|
|||||||
const noteLinkedTasks = ref([]);
|
const noteLinkedTasks = ref([]);
|
||||||
|
|
||||||
const refreshSpaceData = async (spaceId) => {
|
const refreshSpaceData = async (spaceId) => {
|
||||||
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]);
|
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTasks(spaceId)]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSpaces = async () => {
|
const fetchSpaces = async () => {
|
||||||
@@ -212,13 +212,13 @@ export const useSpaceStore = defineStore("space", () => {
|
|||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchTaskStatuses = async (spaceId) => {
|
const fetchTaskStatuses = async (spaceId, taskListId) => {
|
||||||
if (!spaceId) {
|
if (!spaceId || !taskListId) {
|
||||||
taskStatuses.value = [];
|
taskStatuses.value = [];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-statuses`);
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`);
|
||||||
taskStatuses.value = response.data || [];
|
taskStatuses.value = response.data || [];
|
||||||
return taskStatuses.value;
|
return taskStatuses.value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -261,25 +261,25 @@ export const useSpaceStore = defineStore("space", () => {
|
|||||||
await fetchTaskLists(spaceId);
|
await fetchTaskLists(spaceId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTaskStatus = async (spaceId, payload) => {
|
const createTaskStatus = async (spaceId, taskListId, payload) => {
|
||||||
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload);
|
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`, payload);
|
||||||
await fetchTaskStatuses(spaceId);
|
await fetchTaskStatuses(spaceId, taskListId);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTaskStatus = async (spaceId, statusId, payload) => {
|
const updateTaskStatus = async (spaceId, taskListId, statusId, payload) => {
|
||||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`, payload);
|
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`, payload);
|
||||||
await fetchTaskStatuses(spaceId);
|
await fetchTaskStatuses(spaceId, taskListId);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteTaskStatus = async (spaceId, statusId) => {
|
const deleteTaskStatus = async (spaceId, taskListId, statusId) => {
|
||||||
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`);
|
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`);
|
||||||
await fetchTaskStatuses(spaceId);
|
await fetchTaskStatuses(spaceId, taskListId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const reorderTaskStatuses = async (spaceId, orderedStatusIds) => {
|
const reorderTaskStatuses = async (spaceId, taskListId, orderedStatusIds) => {
|
||||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/reorder`, {
|
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/reorder`, {
|
||||||
ordered_status_ids: orderedStatusIds,
|
ordered_status_ids: orderedStatusIds,
|
||||||
});
|
});
|
||||||
taskStatuses.value = response.data || [];
|
taskStatuses.value = response.data || [];
|
||||||
|
|||||||
Reference in New Issue
Block a user