diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index bc73796..466a2c9 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -50,8 +50,8 @@ jobs: - name: Build & Deploy run: | npm run build - # npx vercel --prod --yes --token=${{ secrets.TRELLO_FRONTEND_VERCEL_TOKEN }} - # env: - # VERCEL_ORG_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_ORG_ID }} - # VERCEL_PROJECT_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_PROJECT_ID }} - # VITE_TRELLO_LIKE_API_URL: ${{ vars.VITE_TRELLO_LIKE_API_URL }} + npx vercel --prod --yes --token=${{ secrets.TRELLO_FRONTEND_VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_PROJECT_ID }} + VITE_TRELLO_LIKE_API_URL: ${{ vars.VITE_TRELLO_LIKE_API_URL }} diff --git a/backend/app/dto/response/task/AssignedMemberResponse.scala b/backend/app/dto/response/task/AssignedMemberResponse.scala new file mode 100644 index 0000000..a06cf19 --- /dev/null +++ b/backend/app/dto/response/task/AssignedMemberResponse.scala @@ -0,0 +1,12 @@ +package dto.response.task + +import play.api.libs.json.{Format, Json} + +case class AssignedMemberResponse( + id: Int, + name: String + ) + +object AssignedMemberResponse { + implicit val format: Format[AssignedMemberResponse] = Json.format[AssignedMemberResponse] +} diff --git a/backend/app/dto/response/task/TaskDetailResponse.scala b/backend/app/dto/response/task/TaskDetailResponse.scala index 8fde710..9fc4839 100644 --- a/backend/app/dto/response/task/TaskDetailResponse.scala +++ b/backend/app/dto/response/task/TaskDetailResponse.scala @@ -15,7 +15,8 @@ case class TaskDetailResponse(id: Int, columnId: Int, isCompleted: Boolean, createdAt: Instant, - updatedAt: Instant + updatedAt: Instant, + assignedMembers: Seq[AssignedMemberResponse] = Seq.empty ) object TaskDetailResponse { diff --git a/backend/app/mappers/TaskMapper.scala b/backend/app/mappers/TaskMapper.scala index f812e70..2c53e69 100644 --- a/backend/app/mappers/TaskMapper.scala +++ b/backend/app/mappers/TaskMapper.scala @@ -1,6 +1,6 @@ package mappers -import dto.response.task.TaskDetailResponse +import dto.response.task.{AssignedMemberResponse, TaskDetailResponse} import models.entities.Task object TaskMapper { @@ -21,5 +21,22 @@ object TaskMapper { updatedAt = entity.updatedAt ) } + def toDetailWithAssignMembersResponse(entity: Task,assignedMembers: Seq[AssignedMemberResponse]): TaskDetailResponse = { + TaskDetailResponse( + id = entity.id.getOrElse(0), + name = entity.name, + description = entity.description, + startDate = entity.startDate, + endDate = entity.endDate, + priority = entity.priority.map(_.toString), + status = entity.status.toString, + position = entity.position.getOrElse(0), + columnId = entity.columnId, + isCompleted = entity.isCompleted, + createdAt = entity.createdAt, + updatedAt = entity.updatedAt, + assignedMembers = assignedMembers + ) + } } diff --git a/backend/app/repositories/TaskRepository.scala b/backend/app/repositories/TaskRepository.scala index 9fb05ba..d89a405 100644 --- a/backend/app/repositories/TaskRepository.scala +++ b/backend/app/repositories/TaskRepository.scala @@ -1,8 +1,7 @@ package repositories import db.MyPostgresProfile.api.{columnStatusTypeMapper, projectStatusTypeMapper, taskStatusTypeMapper} -import dto.response.task.AssignMemberToTaskResponse -import dto.response.task.TaskSummaryResponse +import dto.response.task.{AssignMemberToTaskResponse, AssignedMemberResponse, TaskSummaryResponse} import models.Enums.{ColumnStatus, ProjectStatus, TaskStatus} import models.entities.{Task, UserTask} import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} @@ -45,6 +44,15 @@ class TaskRepository@Inject()( query.result.headOption } + def findAssignedMembers(taskId: Int): DBIO[Seq[AssignedMemberResponse]] = { + val query = for { + ut <- userTasks if ut.taskId === taskId + u <- users if u.id === ut.assignedTo + } yield (u.id, u.name) + + query.result.map(_.map { case (id, name) => AssignedMemberResponse(id, name) }) + } + def update(task: Task): DBIO[Int] = { tasks.filter(_.id === task.id).update(task) } diff --git a/backend/app/services/TaskService.scala b/backend/app/services/TaskService.scala index 717f2b1..eaba1d7 100644 --- a/backend/app/services/TaskService.scala +++ b/backend/app/services/TaskService.scala @@ -124,11 +124,13 @@ class TaskService @Inject()(taskRepository: TaskRepository, def getTaskDetailById(taskId: Int, userId: Int): Future[Option[TaskDetailResponse]] = { val action = for { - result <- getTaskAndProjectByTaskId(taskId, userId) - } yield result + (task, _) <- getTaskAndProjectByTaskId(taskId, userId) + assignedMembers <- taskRepository.findAssignedMembers(taskId) + } yield (task, assignedMembers) db.run(action.transactionally).map { - case (task, _) => Some(TaskMapper.toDetailResponse(task)) + case (task, assignedMembers) => + Some(TaskMapper.toDetailWithAssignMembersResponse(task, assignedMembers)) } } diff --git a/frontend/index.html b/frontend/index.html index e0ef3be..7a4ffd6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@
+
diff --git a/frontend/src/components/board/AssignMembers.tsx b/frontend/src/components/board/AssignMembers.tsx new file mode 100644 index 0000000..0ad6b46 --- /dev/null +++ b/frontend/src/components/board/AssignMembers.tsx @@ -0,0 +1,211 @@ +import { fetchBoardMembers } from "@/services/boardService"; +import taskService from "@/services/taskService"; +import { notify } from "@/services/toastService"; +import type { Member } from "@/types"; +import React, { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useParams } from "react-router-dom"; + +interface AssignMembersProps { + assignedMembers?: Member[]; + taskId: number; +} + +const AssignMembers: React.FC = (props) => { + const {boardId} = useParams(); + const [assignedMembers, setAssignedMembers] = useState(props?.assignedMembers || []); + const [members, setMembers] = useState([]) + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + const buttonRef = useRef(null); + const popupRef = useRef(null); + const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); + + const fetchMemebers = async () => { + try { + const membersData = await fetchBoardMembers(Number(boardId)); + setMembers(membersData.data); + } catch (error: any) { + notify.error(error.response?.data?.message); + } + } + + const toggleAssign = async (member: Member) => { + try { + const isAssigned = assignedMembers.some(am => am.id === member.id); + + if (isAssigned) { + // remove + await taskService.removeMember(Number(boardId), props.taskId, member.id); + setAssignedMembers(prev => prev.filter(am => am.id !== member.id)); + notify.success(`${member.name} removed`); + } else { + // assign + await taskService.assignMember(Number(boardId), props.taskId, member.id); + setAssignedMembers(prev => [...prev, member]); + notify.success(`${member.name} assigned`); + } + } catch (error: any) { + notify.error(error.response?.data?.message || "Something went wrong"); + } +}; + + useEffect(() => { + fetchMemebers(); + }, [boardId]); + + const filtered = members.filter((m) => + m.name.toLowerCase().includes(search.toLowerCase()) + ); + + // Calculate popup position + useEffect(() => { + if (isOpen && buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setCoords({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + }); + } + }, [isOpen]); + + // turn off popup when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + popupRef.current && + !popupRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + return ( + +
+

Members

+
+ { + assignedMembers?.map((assignedMember) => ( +
+ + {assignedMember.name.charAt(0).toUpperCase()} + +
+ )) + } + + + + {/* Popup render outside by Portal */} + {isOpen && + coords && + createPortal( +
+ {/* Header */} +
+

Assign Members

+ +
+ + {/* Search */} + setSearch(e.target.value)} + className="w-full text-gray-400 border rounded px-2 py-1 mt-3 mb-3 bg-gray-700 border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + {/* List members */} +
    + {/* Assigned Members */} + {filtered.filter(m => assignedMembers.some(am => am.id === m.id)).length > 0 && ( + <> +
  • Assigned
  • + {filtered + .filter(m => assignedMembers.some(am => am.id === m.id)) + .map(m => ( +
  • toggleAssign(m)} + className="flex items-center justify-between px-2 py-1 rounded bg-gray-800" + > + {m.name} + +
  • + ))} + + )} + + {/* Unassigned Members */} + {filtered.filter(m => !assignedMembers.some(am => am.id === m.id)).length > 0 && ( + <> +
  • Unassigned
  • + {filtered + .filter(m => !assignedMembers.some(am => am.id === m.id)) + .map(m => ( +
  • toggleAssign(m)} + className="flex items-center justify-between px-2 py-1 rounded hover:bg-gray-700 cursor-pointer" + > + {m.name} +
  • + ))} + + )} + + {/* Empty state */} + {filtered.length === 0 && ( +
  • + No members found +
  • + )} +
+
, + document.getElementById("portal")! + )} + +
+
+ + ); +}; + +export default AssignMembers; diff --git a/frontend/src/components/board/TaskDetailModal.tsx b/frontend/src/components/board/TaskDetailModal.tsx index dccfcdc..26e6fb5 100644 --- a/frontend/src/components/board/TaskDetailModal.tsx +++ b/frontend/src/components/board/TaskDetailModal.tsx @@ -1,12 +1,13 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { X, Calendar, Users, List, Edit3, Archive, // Eye, Paperclip, Copy, Trash2, MessageSquareText } from 'lucide-react'; import LoadingContent from '../ui/LoadingContent'; -import type { Item, ItemDetail } from '@/types'; +import type { ItemDetail } from '@/types'; import taskService from '@/services/taskService'; +import AssignMembers from './AssignMembers'; interface TaskModalProps { onClose: () => void; @@ -54,7 +55,7 @@ const TaskDetailModal: React.FC = ({ finally { setIsLoading(false); } - } + }; console.log("Rendering TaskDetailModal for itemId:", itemId, item); @@ -64,11 +65,11 @@ const TaskDetailModal: React.FC = ({ }, [itemId]); return ( -
-
+
+
{ isLoading ? -
+
: <> @@ -116,23 +117,28 @@ const TaskDetailModal: React.FC = ({ Checklist - + */}
+
- {/* Labels Section */} -
-

Labels

-
- {/* BE */} - + {/* Member section */} + + + {/* Labels Section */} +
+

Labels

+
+ BE + +
-
Story Points
+ {/* Description Section */}
diff --git a/frontend/src/services/boardService.ts b/frontend/src/services/boardService.ts index 83f0ad0..dd57a99 100644 --- a/frontend/src/services/boardService.ts +++ b/frontend/src/services/boardService.ts @@ -1,4 +1,4 @@ -import type { ApiResponse, Board, Column, UrlPreviewData } from '@/types'; +import type { ApiResponse, Board, Column, Member, UrlPreviewData } from '@/types'; import axiosClients from './axiosClient'; const previewUrl = '/url-preview'; @@ -20,6 +20,10 @@ const fetchBoardColumns = async (id: number): Promise> => return axiosClients.get(`${projectUrl}/${id}/columns`); }; +const fetchBoardMembers = async (id: number): Promise> => { + return axiosClients.get(`${projectUrl}/${id}/members`); +}; + const fetchActiveBoardTasks = async (id: number): Promise> => { return axiosClients.get(`${projectUrl}/${id}/columns/tasks/active`); }; @@ -63,5 +67,5 @@ export { fetchUrlPreview, fetchBoardDetail, createNewColumn, updateColumn, archiveColumn, restoreColumn, deleteColumn, updateColumnPosititon, fetchBoardColumns, - fetchArchivedColumns, fetchActiveBoardTasks + fetchArchivedColumns, fetchActiveBoardTasks, fetchBoardMembers }; diff --git a/frontend/src/services/taskService.ts b/frontend/src/services/taskService.ts index 0d2f234..a38f57d 100644 --- a/frontend/src/services/taskService.ts +++ b/frontend/src/services/taskService.ts @@ -38,6 +38,15 @@ const taskService = { return axiosClients.delete(`/${taskUrl}/${taskId}`); }, + assignMember(projectId: number, taskId: number, memberId: number): Promise> { + return axiosClients.post(`/${projectUrl}/${projectId}/${taskUrl}/${taskId}/members`, { + userId: memberId + }); + }, + + removeMember(projectId: number, taskId: number, memberId: number): Promise> { + return axiosClients.delete(`/${projectUrl}/${projectId}/${taskUrl}/${taskId}/members/${memberId}`); + }, } export default taskService; \ No newline at end of file diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index 2f7dd14..c0268af 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -52,6 +52,12 @@ export interface UpdateItemRequest { export interface ItemDetail extends Item { description?: string; + assignedMembers?: Member[]; +} + +export interface Member { + id: number; + name: string; } export interface TaskDetail {