diff --git a/src/app/taskmate/trash/page.tsx b/src/app/taskmate/trash/page.tsx index c565127a..89eb240c 100644 --- a/src/app/taskmate/trash/page.tsx +++ b/src/app/taskmate/trash/page.tsx @@ -1,11 +1,14 @@ import React from "react"; +import AsyncBoundary from "@/components/common/AsyncBoundary"; import Trash from "@/components/trash"; const TrashPage = () => { return (
- + + +
); }; diff --git a/src/components/common/SoftButton/SoftButton.stories.tsx b/src/components/common/SoftButton/SoftButton.stories.tsx index fcdcb018..6166b7f8 100644 --- a/src/components/common/SoftButton/SoftButton.stories.tsx +++ b/src/components/common/SoftButton/SoftButton.stories.tsx @@ -39,7 +39,7 @@ export const Default: Story = { export const Gray: Story = { args: { children: "회색버튼", - variant: "gray", + variant: "grayActive", }, }; diff --git a/src/components/common/SoftButton/SoftButton.tsx b/src/components/common/SoftButton/SoftButton.tsx index 97221253..191b56ab 100644 --- a/src/components/common/SoftButton/SoftButton.tsx +++ b/src/components/common/SoftButton/SoftButton.tsx @@ -9,7 +9,8 @@ const softButtonVariants = cva( variants: { variant: { purple: "bg-blue-100 text-blue-800 hover:bg-blue-200", - gray: "bg-background-normal-alternative-2 text-gray-400 ring-1 ring-gray-200 hover:ring-1 hover:bg-background-elevated-normal hover:text-gray-500 active:ring-1 active:ring-blue-900 active:text-blue-800", + grayActive: + "bg-background-normal-alternative-2 ring-1 ring-blue-900 text-blue-800", }, }, defaultVariants: { diff --git a/src/components/trash/PersonalTrash/PersonalTrash.tsx b/src/components/trash/PersonalTrash/PersonalTrash.tsx index 4c4cd587..a1113cbe 100644 --- a/src/components/trash/PersonalTrash/PersonalTrash.tsx +++ b/src/components/trash/PersonalTrash/PersonalTrash.tsx @@ -1,11 +1,29 @@ -import React from "react"; - +"use client"; import TrashEmpty from "@/components/trash/TrashEmpty"; import TrashList from "@/components/trash/TrashList"; +import { trashQueries } from "@/constants/queryKeys/trash.queryKey"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; function PersonalTrash() { - const isEmpty = false; - return
{isEmpty ? : }
; + const { ref, data, isFetchingNextPage } = useInfiniteScroll( + trashQueries.personalTrashList(), + ); + + const items = data.pages.flatMap((page) => page.content); + const isEmpty = data.pages[0].totalElements === 0; + return ( +
+ {isEmpty ? ( + + ) : ( + + )} +
+ ); } export default PersonalTrash; diff --git a/src/components/trash/TeamTrash/TeamTrash.tsx b/src/components/trash/TeamTrash/TeamTrash.tsx index 3adc3e9a..29506d32 100644 --- a/src/components/trash/TeamTrash/TeamTrash.tsx +++ b/src/components/trash/TeamTrash/TeamTrash.tsx @@ -1,11 +1,33 @@ import React from "react"; -import TrashEmpty from "../TrashEmpty"; -import TrashList from "../TrashList"; +import TrashEmpty from "@/components/trash/TrashEmpty"; +import TrashList from "@/components/trash/TrashList"; +import { trashQueries } from "@/constants/queryKeys/trash.queryKey"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; -function TeamTrash() { - const isEmpty = true; - return
{isEmpty ? : }
; +interface TeamTrashProp { + selectedTeamId: number; +} + +function TeamTrash({ selectedTeamId }: TeamTrashProp) { + const { ref, data, isFetchingNextPage } = useInfiniteScroll( + trashQueries.teamTrashList(selectedTeamId), + ); + const items = data.pages.flatMap((page) => page.content); + const isEmpty = data.pages[0].totalElements === 0; + return ( +
+ {isEmpty ? ( + + ) : ( + + )} +
+ ); } export default TeamTrash; diff --git a/src/components/trash/TeamTrash/TeamTrashDropdown.tsx b/src/components/trash/TeamTrash/TeamTrashDropdown.tsx index d6513dea..9fc191f4 100644 --- a/src/components/trash/TeamTrash/TeamTrashDropdown.tsx +++ b/src/components/trash/TeamTrash/TeamTrashDropdown.tsx @@ -2,17 +2,38 @@ import { Icon } from "@/components/common/Icon"; import { useDropdown } from "@/hooks/useDropdown"; -function TeamTrashDropdown() { - const options = [ - "프론트엔드 1팀", - "기획 1팀", - "백엔드 2팀", - "프론테엔드프론테엔드프론테엔드프론테엔드", - ]; +interface Team { + teamId: number; + teamName: string; +} + +interface TeamTrashDropdownProps { + teams: Team[]; + selectedTeamId: number; + onSelect: (teamId: number) => void; +} + +function TeamTrashDropdown({ + teams, + selectedTeamId, + onSelect, +}: TeamTrashDropdownProps) { + const options = teams.map((team) => team.teamName); + const selectedTeamName = teams.find( + (team) => team.teamId === selectedTeamId, + )?.teamName; const { isOpen, selected, toggle, selectItem, containerRef } = useDropdown( options, - options[0], + selectedTeamName, ); + + const handleSelect = (teamName: string) => { + const team = teams.find((team) => team.teamName === teamName); + if (!team) return; + onSelect(team.teamId); + selectItem(teamName); + }; + return (
{isOpen && ( diff --git a/src/components/trash/Trash.tsx b/src/components/trash/Trash.tsx index 32f29383..d101e435 100644 --- a/src/components/trash/Trash.tsx +++ b/src/components/trash/Trash.tsx @@ -1,15 +1,28 @@ "use client"; +import { useSuspenseQuery } from "@tanstack/react-query"; import React, { useState } from "react"; -import PersonalTrash from "./PersonalTrash"; -import TeamTrash from "./TeamTrash"; -import TeamTrashDropdown from "./TeamTrash/TeamTrashDropdown"; -import TrashTabs, { TrashTab } from "./TrashTabs"; +import PersonalTrash from "@/components/trash/PersonalTrash"; +import TeamTrash from "@/components/trash/TeamTrash"; +import TeamTrashDropdown from "@/components/trash/TeamTrash/TeamTrashDropdown"; +import TrashEmpty from "@/components/trash/TrashEmpty"; +import TrashTabs, { TrashTab } from "@/components/trash/TrashTabs"; +import { teamQueries } from "@/features/team/query/team.queryKey"; + +import AsyncBoundary from "../common/AsyncBoundary"; function Trash() { - const [activeTab, setActiveTab] = useState("team"); + const [activeTab, setActiveTab] = useState("personal"); + const { data: teams } = useSuspenseQuery(teamQueries.all()); + const [selectedTeamId, setSeletedTeamId] = useState( + teams[0]?.teamId, + ); + return (
+

+ 삭제된 할 일 +

- {activeTab === "team" && } + {activeTab === "team" && selectedTeamId !== undefined && ( + + )}
- {activeTab === "personal" ? : } + + {activeTab === "personal" ? ( + + ) : selectedTeamId !== undefined ? ( + + ) : ( + + )} +
); diff --git a/src/components/trash/TrashItem/TrashItem.tsx b/src/components/trash/TrashItem/TrashItem.tsx index aa295d81..75654ece 100644 --- a/src/components/trash/TrashItem/TrashItem.tsx +++ b/src/components/trash/TrashItem/TrashItem.tsx @@ -1,55 +1,51 @@ import React from "react"; import { Icon } from "@/components/common/Icon"; +import TrashBadge from "@/components/trash/TrashItem/TrashBadge"; +import { TrashItemData } from "@/features/trash/types/trash.types"; -import TrashBadge from "./TrashBadge"; - -const data = { - content: [ - { - itemType: "GOAL", - id: 42, - deletedAt: "2026-04-15T02:17:29.290Z", - goalName: "이번 주 운동", - todoTitle: "러닝 30분", - }, - ], - page: 0, - size: 7, - totalElements: 24, - totalPages: 4, -}; +interface TrashItemProps extends TrashItemData { + isSelected: boolean; + onToggle: (id: number) => void; +} -function TrashItem() { +function TrashItem({ isSelected, onToggle, ...item }: TrashItemProps) { return (
- onToggle(item.id)} />
-
- - - - 강의 내용을 Notion이나 문서에 요약 정리 강의 - -
-
- - - 디자인 시스템 - -
+ {item.itemType === "GOAL" ? ( +
+ + + {item.goalName} + +
+ ) : ( +
+
+ + + {item.todoTitle} + +
+
+ + + {item.goalName} + +
+
+ )}
); diff --git a/src/components/trash/TrashItem/index.tsx b/src/components/trash/TrashItem/index.tsx index 15ba94fc..00848937 100644 --- a/src/components/trash/TrashItem/index.tsx +++ b/src/components/trash/TrashItem/index.tsx @@ -1 +1,2 @@ +export { default as TrashBadge } from "./TrashBadge"; export { default } from "./TrashItem"; diff --git a/src/components/trash/TrashList/TrashList.tsx b/src/components/trash/TrashList/TrashList.tsx index 6a88f903..659e7343 100644 --- a/src/components/trash/TrashList/TrashList.tsx +++ b/src/components/trash/TrashList/TrashList.tsx @@ -1,32 +1,105 @@ -import React from "react"; +"use client"; +import React, { useState } from "react"; import SoftButton from "@/components/common/SoftButton"; +import { useDeleteTrashMutation } from "@/features/trash/hooks/useDeleteTrashMutation"; +import { useRestoreTrashMutation } from "@/features/trash/hooks/useRestoreTrashMutation"; +import { TrashItemData } from "@/features/trash/types/trash.types"; import TrashItem from "../TrashItem"; -function TrashList() { +interface TrashListProps { + items: TrashItemData[]; + bottomRef: React.RefObject; + isFetchingNextPage: boolean; +} + +function TrashList({ items, bottomRef, isFetchingNextPage }: TrashListProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const { mutate: restoreMutate, isPending: isRestore } = + useRestoreTrashMutation(); + const { mutate: deleteMutate, isPending: isDeleting } = + useDeleteTrashMutation(); + + const handleToggle = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + const handleSelectAll = () => { + if (selectedIds.size === items.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(items.map((item) => item.id))); + } + }; + + const getSelectedTrashItems = () => { + const selectedItems = items.filter((item) => selectedIds.has(item.id)); + return { + goalIds: selectedItems + .filter((item) => item.itemType === "GOAL") + .map((item) => item.id), + todoIds: selectedItems + .filter((item) => item.itemType === "TODO") + .map((item) => item.id), + }; + }; + const handleRestore = () => { + restoreMutate(getSelectedTrashItems(), { + onSuccess: () => setSelectedIds(new Set()), + }); + }; + + const handleDelete = () => { + deleteMutate(getSelectedTrashItems(), { + onSuccess: () => setSelectedIds(new Set()), + }); + }; + return (
-
- +
+ {items.map((item) => ( + + ))} +
+ {isFetchingNextPage && ( +

+ 불러오는 중 +

+ )}
전체 선택
복구 삭제 diff --git a/src/constants/queryKeys/index.ts b/src/constants/queryKeys/index.ts index b00893bb..1bb348dc 100644 --- a/src/constants/queryKeys/index.ts +++ b/src/constants/queryKeys/index.ts @@ -1 +1,2 @@ +export { trashQueries } from "@/constants/queryKeys/trash.queryKey"; export { userQueries } from "@/constants/queryKeys/user.queryKey"; diff --git a/src/constants/queryKeys/trash.queryKey.ts b/src/constants/queryKeys/trash.queryKey.ts new file mode 100644 index 00000000..22720e2b --- /dev/null +++ b/src/constants/queryKeys/trash.queryKey.ts @@ -0,0 +1,36 @@ +import { infiniteQueryOptions } from "@tanstack/react-query"; + +import { + getPersonalTrashList, + getTeamTrashList, +} from "@/features/trash/api/trash.api"; + +import { STALE_TIME } from "../staleTime"; + +const SIZE = 20; + +export const trashQueries = { + all: ["trash"] as const, + + personalTrashList: () => + infiniteQueryOptions({ + queryKey: [...trashQueries.all, "personal"], + initialPageParam: 0, + queryFn: ({ pageParam }: { pageParam: number }) => + getPersonalTrashList({ page: pageParam, size: SIZE }), + getNextPageParam: (lastPage, _, lastPageParam) => + lastPage.page < lastPage.totalPages - 1 ? lastPageParam + 1 : undefined, + staleTime: STALE_TIME.MEDIUM, + }), + + teamTrashList: (teamId: number) => + infiniteQueryOptions({ + queryKey: [...trashQueries.all, "team", teamId], + initialPageParam: 0, + queryFn: ({ pageParam }: { pageParam: number }) => + getTeamTrashList(teamId, { page: pageParam, size: SIZE }), + getNextPageParam: (lastPage, _, lastPageParam) => + lastPage.page < lastPage.totalPages - 1 ? lastPageParam + 1 : undefined, + staleTime: STALE_TIME.MEDIUM, + }), +}; diff --git a/src/features/trash/api/trash.api.ts b/src/features/trash/api/trash.api.ts new file mode 100644 index 00000000..af163972 --- /dev/null +++ b/src/features/trash/api/trash.api.ts @@ -0,0 +1,43 @@ +import { apiClient } from "@/lib/api/client"; +import { ApiResponse } from "@/lib/api/types"; + +import { TrashActionParam, TrashListData } from "../types/trash.types"; + +export async function deleteTrash(data: TrashActionParam) { + const res = await apiClient.delete>("/api/trash", { + body: data, + }); + return res.data; +} + +export async function restoreTrash(data: TrashActionParam) { + const res = await apiClient.post>( + "/api/trash/restore", + data, + ); + return res.data; +} + +export async function getPersonalTrashList(data: { + page?: number; + size?: number; +}) { + const res = await apiClient.get>( + "/api/trash/personal", + { params: data }, + ); + + return res.data; +} + +export async function getTeamTrashList( + teamId: number, + data: { page?: number; size?: number }, +) { + const res = await apiClient.get>( + `/api/trash/teams/${teamId}`, + { params: data }, + ); + + return res.data; +} diff --git a/src/features/trash/hooks/useDeleteTrashMutation/index.ts b/src/features/trash/hooks/useDeleteTrashMutation/index.ts new file mode 100644 index 00000000..ecc435fc --- /dev/null +++ b/src/features/trash/hooks/useDeleteTrashMutation/index.ts @@ -0,0 +1 @@ +export { useDeleteTrashMutation } from "./useDeleteTrashMutation"; diff --git a/src/features/trash/hooks/useDeleteTrashMutation/useDeleteTrashMutation.ts b/src/features/trash/hooks/useDeleteTrashMutation/useDeleteTrashMutation.ts new file mode 100644 index 00000000..8392a5e5 --- /dev/null +++ b/src/features/trash/hooks/useDeleteTrashMutation/useDeleteTrashMutation.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { deleteTrash } from "@/features/trash/api/trash.api"; +import { TrashActionParam } from "@/features/trash/types/trash.types"; +import { useToast } from "@/hooks/useToast"; +import { ApiError } from "@/lib/api/types"; + +export const useDeleteTrashMutation = () => { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: (data: TrashActionParam) => deleteTrash(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["trash"] }); + queryClient.invalidateQueries({ queryKey: ["personal", "goals"] }); + queryClient.invalidateQueries({ queryKey: ["teams", "all"] }); + queryClient.invalidateQueries({ queryKey: ["todo"] }); + toast({ title: "삭제되었습니다", variant: "success" }); + }, + onError: (error: ApiError) => { + toast({ + title: "삭제에 실패했습니다", + description: error.message, + variant: "error", + }); + }, + }); +}; diff --git a/src/features/trash/hooks/useRestoreTrashMutation/index.ts b/src/features/trash/hooks/useRestoreTrashMutation/index.ts new file mode 100644 index 00000000..f9c6affa --- /dev/null +++ b/src/features/trash/hooks/useRestoreTrashMutation/index.ts @@ -0,0 +1 @@ +export { useRestoreTrashMutation } from "./useRestoreTrashMutation"; diff --git a/src/features/trash/hooks/useRestoreTrashMutation/useRestoreTrashMutation.ts b/src/features/trash/hooks/useRestoreTrashMutation/useRestoreTrashMutation.ts new file mode 100644 index 00000000..4d30fc25 --- /dev/null +++ b/src/features/trash/hooks/useRestoreTrashMutation/useRestoreTrashMutation.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { restoreTrash } from "@/features/trash/api/trash.api"; +import { TrashActionParam } from "@/features/trash/types/trash.types"; +import { useToast } from "@/hooks/useToast"; +import { ApiError } from "@/lib/api/types"; + +export const useRestoreTrashMutation = () => { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: (data: TrashActionParam) => restoreTrash(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["trash"] }); + queryClient.invalidateQueries({ queryKey: ["personal", "goals"] }); + queryClient.invalidateQueries({ queryKey: ["teams", "all"] }); + queryClient.invalidateQueries({ queryKey: ["todo"] }); + + toast({ title: "복구되었습니다", variant: "success" }); + }, + onError: (error: ApiError) => { + toast({ + title: "복구에 실패했습니다", + description: error.message, + variant: "error", + }); + }, + }); +}; diff --git a/src/features/trash/types/trash.types.ts b/src/features/trash/types/trash.types.ts new file mode 100644 index 00000000..d275a9b7 --- /dev/null +++ b/src/features/trash/types/trash.types.ts @@ -0,0 +1,21 @@ +export interface TrashItemData { + itemType: "GOAL" | "TODO"; + id: number; + deleteAt: string; + teamName: string; + goalName: string; + todoTitle: string; +} + +export interface TrashListData { + content: TrashItemData[]; + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export interface TrashActionParam { + goalIds: number[]; + todoIds: number[]; +}