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