From ec3e031cddaa01a702ec1f2b24d374e2a23026d4 Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Mon, 23 Feb 2026 09:59:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/history/history.module.css | 574 ++++++++++++++++++ src/app/(root)/history/hooks/queries.ts | 251 ++++++++ src/app/(root)/history/page.tsx | 684 ++++++++++++++++++++++ 3 files changed, 1509 insertions(+) create mode 100644 src/app/(root)/history/history.module.css create mode 100644 src/app/(root)/history/hooks/queries.ts create mode 100644 src/app/(root)/history/page.tsx diff --git a/src/app/(root)/history/history.module.css b/src/app/(root)/history/history.module.css new file mode 100644 index 0000000..ccdfb1f --- /dev/null +++ b/src/app/(root)/history/history.module.css @@ -0,0 +1,574 @@ +:global(html) { + height: 100%; +} +:global(body) { + height: 100%; + min-height: 100dvh; +} + +.page { + display: flex; + min-height: 100dvh; + background: var(--color-background-secondary, #f5f6f8); + align-items: stretch; + overflow-x: hidden; +} + +/* ===== Desktop Sidebar ===== */ +.desktopSidebar { + flex-shrink: 0; + align-self: stretch; + display: flex; + height: auto; + min-height: 100%; +} + +/* ✅ list 페이지와 동일: aside는 visible, 내용 영역만 hidden */ +:global([class*='Sidebar-module__'][class*='sidebar']) { + overflow: visible !important; +} +:global([class*='Sidebar-module__'][class*='content']) { + overflow: hidden !important; +} + +/* ✅ 텍스트 줄바꿈/넘침 방지 (list 동일) */ +:global([class*='Sidebar-module__'] button), +:global([class*='Sidebar-module__'] a), +:global([class*='Sidebar-module__'] span), +:global([class*='Sidebar-module__'] p) { + max-width: 100% !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; +} + +/* ===== Mobile GNB ===== */ +.mobileGnb { + display: none; +} + +/* ✅ 1024~1199에서도 Sidebar 강제 표시 (list 동일) */ +@media (min-width: 1024px) and (max-width: 1199px) { + .desktopSidebar { + display: flex !important; + } + .mobileGnb { + display: none !important; + } + :global([class*='Sidebar-module__'][class*='sidebar']) { + display: flex !important; + } +} + +/* ✅ 모바일/태블릿은 1023px 이하 */ +@media (max-width: 1023px) { + .desktopSidebar { + display: none; + } + .mobileGnb { + display: block; + position: fixed; + top: 0; + left: 0; + right: 0; + background: var(--color-background-inverse, #fff); + padding-top: env(safe-area-inset-top); + z-index: 5; + } +} + +/* ===== main ===== */ +.main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + padding: 120px 85px 80px; + box-sizing: border-box; +} + +@media (max-width: 1023px) { + .main { + padding: 0; + padding-top: calc(60px + env(safe-area-inset-top)); + } +} + +/* TeamHeader wrap */ +.teamHeaderWrap { + width: 100%; +} + +/* ✅ 모바일 패딩 18px + margin-top 17px */ +@media (max-width: 767px) { + .teamHeaderWrap { + margin-top: 17px; + padding: 0 18px; + box-sizing: border-box; + } +} + +/* ✅ 태블릿 패딩 26px + margin-top 32px */ +@media (min-width: 768px) and (max-width: 1023px) { + .teamHeaderWrap { + margin-top: 32px; + padding: 0 26px; + box-sizing: border-box; + } +} + +/* ✅ PC에서는 TeamHeader 가운데 정렬만 */ +@media (min-width: 1024px) { + :global(.TeamHeader-module__H3kcRq__container) { + width: 100%; + max-width: 1200px; + margin-left: auto !important; + margin-right: auto !important; + } +} + +/* ===== body layout ===== */ +.body { + display: flex; + align-items: flex-start; + justify-content: center; + min-width: 0; + margin-top: 46px; + gap: 48px; +} + +/* ✅ PC gap 76 */ +@media (min-width: 1024px) { + .body { + gap: 76px; + } +} + +/* 모바일/태블릿 */ +@media (max-width: 743px) { + .body { + margin-top: 25px; + padding: 0 16px; + box-sizing: border-box; + flex-direction: column; + gap: 18px; + } +} +@media (min-width: 744px) and (max-width: 1023px) { + .body { + margin-top: 25px; + padding: 0 16px; + box-sizing: border-box; + flex-direction: column; + gap: 22px; + } +} + +/* ===== PC Left ===== */ +.leftCol { + width: 270px; + flex-shrink: 0; + display: flex; + flex-direction: column; +} + +.leftTitle { + margin: 0 0 22px 0; + font-size: 18px; + font-weight: 800; + color: #0f172a; +} + +.leftScroll { + max-height: 768px; + overflow: auto; + padding-right: 10px; + box-sizing: border-box; +} + +.monthSection { + margin-bottom: 40px; +} + +.monthTitle { + font-size: 14px; + font-weight: 800; + color: #0f172a; + margin-bottom: 12px; + cursor: pointer; +} + +.cardStack { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ===== Right ===== */ +.rightCol { + min-width: 0; + display: flex; + justify-content: center; +} + +@media (max-width: 1023px) { + .rightCol { + width: 100%; + } +} + +.whiteBox { + background: #ffffff; + border-radius: 24px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06); + border: 1px solid #eef2f7; + overflow: visible; + display: flex; + flex-direction: column; + position: relative; + max-width: 100%; + min-height: 768px; +} + +@media (min-width: 1024px) { + .whiteBox { + width: 768px; + } +} +@media (max-width: 1023px) { + .whiteBox { + width: 100%; + } +} +@media (min-width: 744px) and (max-width: 1023px) { + .whiteBox { + min-height: 920px; + } +} +@media (max-width: 743px) { + .whiteBox { + min-height: 662px; + } +} + +.rightPanel { + flex: 1; + display: flex; + flex-direction: column; + box-sizing: border-box; + padding: 46px 37px 52px 37px; +} + +@media (min-width: 744px) and (max-width: 1023px) { + .rightPanel { + padding: 46px 30px 52px 30px; + } +} +@media (max-width: 743px) { + .rightPanel { + padding: 31px 26px 52px 22px; + } +} + +/* header */ +.boxHeader { + position: relative; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.boxHeaderLeft { + display: inline-flex; + align-items: center; + gap: 10px; + min-width: 0; + margin: 0 auto; +} + +.monthLabel { + font-size: 16px; + font-weight: 800; + color: #0f172a; + white-space: nowrap; +} + +.calendarBtn { + width: 28px; + height: 28px; + border-radius: 999px; + border: none; + background: #f1f5f9; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + position: absolute; + right: 0px; + top: 0px; +} + +@media (min-width: 1024px) { + .calendarBtn { + right: 0px; + top: 0px; + } +} + +.calendarPopover { + position: absolute; + top: calc(0px + 28px + 10px); + right: 22px; + z-index: 20000; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 14px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); + padding: 10px; +} + +@media (min-width: 1024px) { + .calendarPopover { + top: 44px; + right: 37px; + } +} + +/* Chip row */ +.chipRow { + display: flex; + flex-wrap: wrap; + gap: 4px; + box-sizing: border-box; + margin-top: 27px; +} + +/* Body */ +.boxBody { + flex: 1; + overflow: auto; + box-sizing: border-box; + margin-top: 18px; +} + +.emptyState { + text-align: center; + color: #94a3b8; + font-size: 14px; + line-height: 1.6; + padding: 80px 0 40px; +} + +.taskGroupWrap { + display: flex; + flex-direction: column; + gap: 22px; +} + +.daySection { + display: flex; + flex-direction: column; + gap: 14px; +} + +.dateDivider { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + margin: 6px 0 6px; +} + +.dateDividerLine { + height: 1px; + background: #e2e8f0; +} + +.dateDividerText { + font-size: 13px; + font-weight: 700; + color: #64748b; + white-space: nowrap; +} + +.taskList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.taskRow { + position: relative; + cursor: pointer; +} + +/* ✅ Chip border (small) */ +:global(.Chip-module__j4Ti-q__chip.Chip-module__j4Ti-q__small) { + border: 1px solid var(--color-background-tertiary, #e2e8f0) !important; +} + +/* ✅ 태블릿 large 보더 */ +@media (min-width: 744px) and (max-width: 1024px) { + :global(.Chip-module__j4Ti-q__chip.Chip-module__j4Ti-q__large) { + border: 1px solid var(--color-background-tertiary, #e2e8f0) !important; + } +} + +/* task kebab menu */ +.taskMenu { + position: absolute; + top: 52px; + right: 0px; + width: 120px; + height: 80px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); + z-index: 13000; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; + list-style: none; + margin: 0; +} + +.taskMenuItem { + width: 100%; + height: 32px; + border: none; + background: transparent; + cursor: pointer; + text-align: center; + padding: 0 10px; + border-radius: 10px; + font-size: 14px; + font-weight: 400; + color: #0f172a; +} + +.taskMenuItem:hover { + background: #f1f5f9; +} + +/* ===== Detail Overlay (history 원본 유지) ===== */ +.detailOverlay { + position: fixed; + top: 0; + right: 0; + height: 100dvh; + width: 779px; + max-width: 100vw; + background: #ffffff; + border-left: 1px solid #e2e8f0; + box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12); + z-index: 20000; + display: flex; + flex-direction: column; + transform: translateX(100%); + opacity: 0; + transition: + transform 260ms ease, + opacity 260ms ease; + will-change: transform, opacity; +} + +.detailOpen { + transform: translateX(0); + opacity: 1; +} + +.detailClose { + transform: translateX(100%); + opacity: 0; +} + +.detailInner { + height: 100%; + overflow: auto; + padding: 28px; +} + +/* ✅ TaskDetailCard header 레이아웃 강제 */ +.detailOverlay :global([class*='TaskDetailCard-module__'][class*='header']) { + display: grid !important; + grid-template-columns: 1fr auto !important; + grid-template-rows: auto auto !important; + grid-template-areas: + 'close close' + 'title kebab' !important; + align-items: center !important; +} + +.detailOverlay :global([class*='TaskDetailCard-module__'][class*='closeButton']) { + grid-area: close !important; + justify-self: start !important; + align-self: start !important; +} + +.detailOverlay :global([class*='TaskDetailCard-module__'][class*='title']) { + grid-area: title !important; +} + +.detailOverlay :global([class*='TaskDetailCard-module__'][class*='kebabWrapper']) { + grid-area: kebab !important; + justify-self: end !important; +} + +/* 간격: PC/태블릿 74px, 모바일 20px */ +@media (min-width: 744px) { + .detailOverlay :global([class*='TaskDetailCard-module__'][class*='title']), + .detailOverlay :global([class*='TaskDetailCard-module__'][class*='kebabWrapper']) { + margin-top: 74px !important; + } +} +@media (max-width: 767px) { + .detailOverlay :global([class*='TaskDetailCard-module__'][class*='title']), + .detailOverlay :global([class*='TaskDetailCard-module__'][class*='kebabWrapper']) { + margin-top: 20px !important; + } +} + +@media (max-width: 1024px) { + .detailOverlay { + left: 0; + right: 0; + width: 100%; + max-width: 100vw; + border-left: none; + box-shadow: none; + transform: none !important; + opacity: 1 !important; + transition: none !important; + } + + .detailOpen, + .detailClose { + transform: none !important; + opacity: 1 !important; + transition: none !important; + } +} + +@media (max-width: 767px) { + .detailInner { + padding: 12px 16px 62px; + } +} + +:global(.TaskDetailCard-module__8btaCa__title) { + margin: 0 !important; +} + +/* ✅ 히스토리라서 디테일 내부 inverse FilledRoundButton 클릭 금지 */ +.detailOverlay + :global( + .ButtonBehavior-module__7wasrG__buttonBase.FilledRoundButton-module__5BYrQG__root.FilledRoundButton-module__5BYrQG__inverse.FilledRoundButton-module__5BYrQG__shadow + ) { + pointer-events: none !important; + cursor: default !important; + opacity: 0.6 !important; +} diff --git a/src/app/(root)/history/hooks/queries.ts b/src/app/(root)/history/hooks/queries.ts new file mode 100644 index 0000000..5a148ff --- /dev/null +++ b/src/app/(root)/history/hooks/queries.ts @@ -0,0 +1,251 @@ +import { useQueries, useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +/** + * ✅ BFF 프록시 기반 (httpOnly cookie 인증) + */ +function proxyUrl(path: string) { + const p = path.startsWith('/') ? path.slice(1) : path; + return `/api/proxy/${p}`; +} + +async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(proxyUrl(path), { ...init, credentials: 'include' }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(text || `Request failed: ${res.status}`); + } + if (res.status === 204) return undefined as T; + + const ct = res.headers.get('content-type') ?? ''; + if (ct.includes('application/json')) return (await res.json()) as T; + return (await res.text()) as unknown as T; +} + +export type ApiFrequency = 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY'; + +export type Group = { + id: number; + name: string; + image?: string | null; +}; + +export type Membership = { group: Group }; + +export type MeResponse = { + id: number; + nickname: string; + image: string | null; + memberships: Membership[]; +}; + +export type TaskList = { + id: number; + name: string; + displayIndex: number; +}; + +export type GroupDetailResponse = { + id: number; + name: string; + taskLists: TaskList[]; +}; + +export type DoneTask = { + id: number; + name: string; + description?: string | null; + + // doneAt(완료시각) / date(할일 날짜) 둘중 하나는 있다고 가정 + doneAt?: string | null; + date?: string | null; + + commentCount?: number | null; + frequency?: ApiFrequency | null; + + writer?: { + id: number; + nickname: string; + image: string | null; + } | null; + + // taskListId 필수로 붙여서 내려오는 케이스가 많음 (없으면 우리가 주입) + taskListId?: number | null; +}; + +export type DoneTasksResponse = { + tasksDone: DoneTask[]; +}; + +export type CommentWriter = { id: number; nickname: string; image: string | null }; + +export type ApiComment = { + id: number; + content: string; + createdAt: string; + updatedAt: string; + taskId?: number; + userId?: number; + user?: CommentWriter; +}; + +// ===== Queries ===== + +export function useMe() { + return useQuery({ + queryKey: ['me'], + queryFn: () => apiFetch('user/me'), + staleTime: 60_000, + }); +} + +/** ✅ 그룹 상세 (taskLists) */ +export function useGroupDetail(groupId: number) { + return useQuery({ + queryKey: ['groupDetail', groupId], + queryFn: () => apiFetch(`groups/${groupId}`), + enabled: Number.isFinite(groupId) && groupId > 0, + staleTime: 30_000, + }); +} + +/** + * ✅ "완료된 task" 조회 (taskList 단위) + * - 월 선택이면 from/to로 범위를 주는게 제일 흔함. + * - 스웨거랑 다르면 아래 URL 1줄만 바꿔. + */ +export function useDoneTasksByTaskList(params: { + groupId: number; + taskListId: number; + fromIso: string; + toIso: string; +}) { + const { groupId, taskListId, fromIso, toIso } = params; + + return useQuery({ + queryKey: ['doneTasks', groupId, taskListId, fromIso, toIso], + queryFn: async () => { + // ✅ TODO: 스웨거 경로 확인해서 여기만 맞추면 끝 + // 흔한 형태 예시: + // 1) groups/{groupId}/task-lists/{taskListId}/tasks/done?from=...&to=... + // 2) groups/{groupId}/task-lists/{taskListId}/tasks?done=true&from=...&to=... + const path = `groups/${groupId}/task-lists/${taskListId}/tasks/done?from=${encodeURIComponent( + fromIso, + )}&to=${encodeURIComponent(toIso)}`; + + const data = await apiFetch(path); + + // taskListId가 서버 응답에 없으면 주입해서 history에서 필터링 가능하게 + const tasks = (data?.tasksDone ?? []).map((t) => ({ + ...t, + taskListId: (t.taskListId ?? taskListId) as number, + })); + + return { tasksDone: tasks }; + }, + enabled: + Number.isFinite(groupId) && + groupId > 0 && + Number.isFinite(taskListId) && + taskListId > 0 && + !!fromIso && + !!toIso, + staleTime: 10_000, + }); +} + +/** + * ✅ 여러 taskList의 done tasks를 한 번에 모으는 Hook + * - rules-of-hooks 위반 없이, useQueries는 컴포넌트 최상단에서만 1번 호출. + */ +export function useDoneTasksForTaskLists(params: { + groupId: number; + taskListIds: number[]; + fromIso: string; + toIso: string; +}) { + const { groupId, taskListIds, fromIso, toIso } = params; + + const queries = useQueries({ + queries: taskListIds.map((taskListId) => ({ + queryKey: ['doneTasks', groupId, taskListId, fromIso, toIso], + queryFn: async () => { + const path = `groups/${groupId}/task-lists/${taskListId}/tasks/done?from=${encodeURIComponent( + fromIso, + )}&to=${encodeURIComponent(toIso)}`; + + const data = await apiFetch(path); + const tasks = (data?.tasksDone ?? []).map((t) => ({ + ...t, + taskListId: (t.taskListId ?? taskListId) as number, + })); + return { taskListId, tasksDone: tasks }; + }, + enabled: + Number.isFinite(groupId) && + groupId > 0 && + Number.isFinite(taskListId) && + taskListId > 0 && + !!fromIso && + !!toIso, + staleTime: 10_000, + })), + }); + + const isLoading = queries.some((q) => q.isLoading); + const isError = queries.some((q) => q.isError); + + const tasksDoneAll = queries.flatMap((q) => (q.data?.tasksDone ?? []) as DoneTask[]); + + return { tasksDoneAll, isLoading, isError }; +} + +// ===== Comments ===== + +export function useTaskComments(taskId: number) { + return useQuery({ + queryKey: ['taskComments', taskId], + queryFn: () => apiFetch(`tasks/${taskId}/comments`), + enabled: Number.isFinite(taskId) && taskId > 0, + }); +} + +export function useCreateTaskComment(taskId: number) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { content: string }) => + apiFetch(`tasks/${taskId}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: ['taskComments', taskId] }); + }, + }); +} + +// ===== Direct mutations for history (hook in map 금지 회피) ===== + +export async function patchTaskDone(params: { + groupId: number; + taskListId: number; + taskId: number; + done: boolean; +}) { + const { groupId, taskListId, taskId, done } = params; + + return apiFetch(`groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ done }), + }); +} + +export async function deleteTask(params: { groupId: number; taskListId: number; taskId: number }) { + const { groupId, taskListId, taskId } = params; + + return apiFetch(`groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}`, { + method: 'DELETE', + }); +} diff --git a/src/app/(root)/history/page.tsx b/src/app/(root)/history/page.tsx new file mode 100644 index 0000000..838210c --- /dev/null +++ b/src/app/(root)/history/page.tsx @@ -0,0 +1,684 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState, type MouseEvent, useSyncExternalStore } from 'react'; +import Image from 'next/image'; +import { useQueryClient } from '@tanstack/react-query'; + +import styles from './history.module.css'; + +import Sidebar from '@/components/sidebar/Sidebar'; +import MobileHeader from '@/components/sidebar/MobileHeader'; +import MobileDrawer from '@/components/sidebar/MobileDrawer'; + +import SidebarButton from '@/components/sidebar/SidebarButton'; +import SidebarAddButton from '@/components/sidebar/SidebarAddButton'; + +import TeamHeader from '@/components/team-header'; +import ArrowButton from '@/components/Button/domain/ArrowButton/ArrowButton'; + +import TaskCard from '@/components/Card/TaskCard/TaskCard'; +import Chip from '@/components/Chip/Chip'; +import TaskListItem from '@/components/list/TaskListItem'; +import TaskDetailCard from '@/components/Card/TaskDetailCard/TaskDetailCard'; + +import Calendar from '@/components/calendar/Calendar'; + +import chessSmall from '@/assets/icons/chess/chessSmall.svg'; +import boardSmall from '@/assets/icons/board/boardSmall.svg'; +import calendarIcon from '@/assets/icons/calender/calenderSmall.svg'; + +import { + type ApiFrequency, + type DoneTask, + useCreateTaskComment, + useDoneTasksForTaskLists, + useGroupDetail, + useMe, + useTaskComments, + patchTaskDone, + deleteTask, +} from './hooks/queries'; + +// ===== no setState in effect: media query ===== +function useMediaQuery(query: string) { + return useSyncExternalStore( + (onStoreChange) => { + const mql = window.matchMedia(query); + mql.addEventListener('change', onStoreChange); + return () => mql.removeEventListener('change', onStoreChange); + }, + () => window.matchMedia(query).matches, + () => false, + ); +} + +type Writer = { id: number; nickname: string; image: string | null }; +type UiComment = { + id: number; + content: string; + createdAt: string; + updatedAt: string; + taskId: number; + userId: number; + user: Writer; +}; + +function ymFromDate(d: Date) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; +} +function formatYearMonthFromKey(ym: string) { + const [y, m] = ym.split('-'); + return `${y}년 ${Number(m)}월`; +} +function formatKoreanDateFromIso(iso: string) { + const d = new Date(iso); + const yyyy = d.getFullYear(); + const mm = d.getMonth() + 1; + const dd = d.getDate(); + const week = ['일', '월', '화', '수', '목', '금', '토'][d.getDay()]; + return `${yyyy}년 ${mm}월 ${dd}일 (${week})`; +} +function frequencyLabel(freq?: ApiFrequency | null) { + if (freq === 'DAILY') return '매일 반복'; + if (freq === 'WEEKLY') return '매주 반복'; + if (freq === 'MONTHLY') return '매월 반복'; + return undefined; +} +function monthRangeIso(ym: string) { + const [y, m] = ym.split('-').map(Number); + const from = new Date(y, m - 1, 1, 0, 0, 0, 0); + const to = new Date(y, m, 1, 0, 0, 0, 0); // next month start + return { fromIso: from.toISOString(), toIso: to.toISOString() }; +} + +/** ✅ 체크박스/케밥/버튼 클릭이면 디테일 오픈 금지 */ +function isOpenDetailBlockedTarget(target: HTMLElement | null) { + if (!target) return false; + + if (target.closest('button, a, input, textarea, select, [role="button"]')) return true; + if (target.closest('[role="checkbox"]')) return true; + if (target.closest('[aria-checked]')) return true; + + const labeled = target.closest('[aria-label]') as HTMLElement | null; + if (labeled) { + const v = (labeled.getAttribute('aria-label') ?? '').toLowerCase(); + if (v.includes('체크') || v.includes('완료') || v.includes('더보기') || v.includes('kebab')) { + return true; + } + } + + const cls = (target.className ?? '').toString().toLowerCase(); + if (cls.includes('checkbox') || cls.includes('kebab') || cls.includes('more')) return true; + + if (target.tagName.toLowerCase() === 'svg' || target.tagName.toLowerCase() === 'path') { + const p = target.parentElement; + if (p && isOpenDetailBlockedTarget(p)) return true; + } + + return false; +} + +export default function HistoryPage() { + const qc = useQueryClient(); + const desktopSidebarRef = useRef(null); + + const isPc = useMediaQuery('(min-width: 1025px)'); + const isMobileUi = useMediaQuery('(max-width: 1024px)'); + + // mobile drawer + const [drawerOpen, setDrawerOpen] = useState(false); + const toggleDrawer = () => setDrawerOpen((p) => !p); + const closeDrawer = () => setDrawerOpen(false); + + // ===== API ===== + const { data: me } = useMe(); + + const groups = useMemo(() => { + const arr = (me?.memberships ?? []).map((m) => m.group); + return arr.filter((g, idx) => arr.findIndex((x) => x.id === g.id) === idx); + }, [me?.memberships]); + + // ✅ 기본 group은 파생값, 유저 선택만 state + const [userSelectedGroupId, setUserSelectedGroupId] = useState(null); + const defaultGroupId = useMemo(() => groups[0]?.id ?? 0, [groups]); + const activeGroupId = userSelectedGroupId ?? defaultGroupId; + + const activeGroup = useMemo( + () => groups.find((g) => g.id === activeGroupId) ?? null, + [groups, activeGroupId], + ); + + // group detail -> taskLists(=카테고리) + const { data: groupDetail } = useGroupDetail(activeGroupId); + const taskLists = useMemo(() => { + return (groupDetail?.taskLists ?? []).slice().sort((a, b) => a.displayIndex - b.displayIndex); + }, [groupDetail?.taskLists]); + + // ===== month ===== + const [userSelectedMonth, setUserSelectedMonth] = useState(null); + + // 기본 월: "현재월"로 두고, 월에 데이터가 없으면 empty state 유지 (list처럼 강제 세팅 X) + const defaultMonth = useMemo(() => ymFromDate(new Date()), []); + const selectedMonth = userSelectedMonth ?? defaultMonth; + + const { fromIso, toIso } = useMemo(() => monthRangeIso(selectedMonth), [selectedMonth]); + + // ===== done tasks (모든 taskList를 한번에 모아오기) ===== + const taskListIds = useMemo(() => taskLists.map((t) => t.id), [taskLists]); + const { tasksDoneAll } = useDoneTasksForTaskLists({ + groupId: activeGroupId, + taskListIds, + fromIso, + toIso, + }); + + // ===== category selection (taskList) ===== + const [selectedTaskListId, setSelectedTaskListId] = useState(null); + + // "기본 카테고리"는 파생값(첫 taskList), effect로 setState 하지 않음 + const effectiveTaskListId = selectedTaskListId ?? taskLists[0]?.id ?? null; + + const categoriesInMonth = useMemo(() => { + // taskList별 완료개수 + const map = new Map(); + tasksDoneAll.forEach((t) => { + const id = (t.taskListId ?? 0) as number; + if (!id) return; + map.set(id, (map.get(id) ?? 0) + 1); + }); + + return taskLists.map((tl) => ({ + id: tl.id, + label: tl.name, + count: map.get(tl.id) ?? 0, + })); + }, [taskLists, tasksDoneAll]); + + const filteredTasks = useMemo(() => { + if (!effectiveTaskListId) return []; + return tasksDoneAll.filter((t) => (t.taskListId ?? 0) === effectiveTaskListId); + }, [tasksDoneAll, effectiveTaskListId]); + + const tasksByDate = useMemo(() => { + const map = new Map(); + filteredTasks.forEach((t) => { + const iso = t.doneAt ?? t.date ?? ''; + const dayKey = iso ? iso.slice(0, 10) : '1970-01-01'; + const arr = map.get(dayKey) ?? []; + arr.push(t); + map.set(dayKey, arr); + }); + + const keys = Array.from(map.keys()).sort((a, b) => (a > b ? 1 : -1)); + return keys.map((k) => ({ dayKey: k, tasks: map.get(k)! })); + }, [filteredTasks]); + + // ===== UI states ===== + const [openedTaskMenuId, setOpenedTaskMenuId] = useState(null); + const [calendarOpen, setCalendarOpen] = useState(false); + + // detail overlay + const [detailMounted, setDetailMounted] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); + + const [selectedTaskId, setSelectedTaskId] = useState(0); + + const effectiveSelectedTaskId = useMemo(() => { + if (filteredTasks.length === 0) return 0; + if (filteredTasks.some((t) => t.id === selectedTaskId)) return selectedTaskId; + return filteredTasks[0].id; + }, [filteredTasks, selectedTaskId]); + + const selectedTask = useMemo(() => { + if (!effectiveSelectedTaskId) return null; + return filteredTasks.find((t) => t.id === effectiveSelectedTaskId) ?? null; + }, [filteredTasks, effectiveSelectedTaskId]); + + // comments + const { data: apiComments = [] } = useTaskComments(effectiveSelectedTaskId); + const createComment = useCreateTaskComment(effectiveSelectedTaskId); + + const meWriter: Writer = useMemo( + () => ({ + id: me?.id ?? 0, + nickname: me?.nickname ?? '', + image: me?.image ?? null, + }), + [me], + ); + + const detailComments: UiComment[] = useMemo(() => { + return apiComments.map((c) => ({ + id: c.id, + content: c.content, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + taskId: c.taskId ?? effectiveSelectedTaskId, + userId: c.userId ?? c.user?.id ?? 0, + user: { + id: c.user?.id ?? 0, + nickname: c.user?.nickname ?? '', + image: c.user?.image ?? null, + }, + })); + }, [apiComments, effectiveSelectedTaskId]); + + function closeDetailImmediate() { + setDetailOpen(false); + setDetailMounted(false); + } + function openDetail() { + setDetailMounted(true); + requestAnimationFrame(() => setDetailOpen(true)); + } + function closeDetail() { + setDetailOpen(false); + window.setTimeout(() => setDetailMounted(false), 260); + } + + const handleOpenDetail = (taskId: number) => { + if (detailMounted && detailOpen && taskId === effectiveSelectedTaskId) { + closeDetail(); + return; + } + setSelectedTaskId(taskId); + openDetail(); + }; + + const invalidateCurrentMonth = async () => { + // 월 범위/카테고리 쿼리키 전부 무효화 + await qc.invalidateQueries({ queryKey: ['doneTasks', activeGroupId] }); + }; + + async function apiToggleDone(task: DoneTask, done: boolean) { + if (!activeGroupId || !task.taskListId) return; + await patchTaskDone({ + groupId: activeGroupId, + taskListId: task.taskListId, + taskId: task.id, + done, + }); + await invalidateCurrentMonth(); + } + + async function apiDelete(task: DoneTask) { + if (!activeGroupId || !task.taskListId) return; + await deleteTask({ groupId: activeGroupId, taskListId: task.taskListId, taskId: task.id }); + await invalidateCurrentMonth(); + } + + // outside click close + useEffect(() => { + const onDoc = (ev: globalThis.MouseEvent) => { + const t = ev.target as HTMLElement | null; + if (!t) return; + + if (openedTaskMenuId !== null && !t.closest(`.${styles.taskMenu}`)) setOpenedTaskMenuId(null); + + if ( + calendarOpen && + !t.closest(`.${styles.calendarPopover}`) && + !t.closest(`.${styles.calendarBtn}`) + ) { + setCalendarOpen(false); + } + }; + + document.addEventListener('click', onDoc); + return () => document.removeEventListener('click', onDoc); + }, [openedTaskMenuId, calendarOpen]); + + // handlers + const changeTeam = (groupId: number) => { + setUserSelectedGroupId(groupId); + + // 팀 바뀌면 선택 리셋 (effect로 setState 금지) + setSelectedTaskListId(null); + setOpenedTaskMenuId(null); + setCalendarOpen(false); + closeDetailImmediate(); + }; + + const onPrevMonth = () => { + const base = new Date(`${selectedMonth}-01T00:00:00`); + base.setMonth(base.getMonth() - 1); + setUserSelectedMonth(ymFromDate(base)); + setOpenedTaskMenuId(null); + setCalendarOpen(false); + closeDetailImmediate(); + }; + + const onNextMonth = () => { + const base = new Date(`${selectedMonth}-01T00:00:00`); + base.setMonth(base.getMonth() + 1); + setUserSelectedMonth(ymFromDate(base)); + setOpenedTaskMenuId(null); + setCalendarOpen(false); + closeDetailImmediate(); + }; + + const monthLabel = formatYearMonthFromKey(selectedMonth); + const chipSize = isMobileUi ? 'small' : 'large'; + + return ( +
+ {/* PC Sidebar */} + {isPc ? ( +
+ + } + > + {(isCollapsed) => ( + <> + {groups.map((g) => ( + } + label={g.name} + iconOnly={isCollapsed} + isActive={g.id === activeGroupId} + onClick={() => changeTeam(g.id)} + /> + ))} + + {!isCollapsed ? {}} /> : null} + +
+ + } + label="자유게시판" + iconOnly={isCollapsed} + onClick={() => {}} + /> + + )} +
+
+ ) : null} + + {/* Mobile */} + {isMobileUi ? ( +
+ + } + onMenuClick={toggleDrawer} + onProfileClick={() => {}} + /> +
+ ) : null} + + {isMobileUi ? ( + + <> + {groups.map((g) => ( + } + label={g.name} + isActive={g.id === activeGroupId} + onClick={() => { + changeTeam(g.id); + closeDrawer(); + }} + /> + ))} + + + +
+ + } + label="자유게시판" + onClick={closeDrawer} + /> + +
+ ) : null} + + {/* Main */} +
+
+ +
+ +
+ {/* LEFT: PC만 - taskList를 카드로 */} + {isPc ? ( + + ) : null} + + {/* RIGHT */} +
+
+
+
+
+ + {monthLabel} + +
+ + + + {calendarOpen ? ( +
e.stopPropagation()} + role="dialog" + aria-label="캘린더" + > + { + if (!d) return; + setUserSelectedMonth(ymFromDate(d)); + setCalendarOpen(false); + setOpenedTaskMenuId(null); + closeDetailImmediate(); + }} + /> +
+ ) : null} +
+ + {/* Mobile: taskList를 Chip으로 */} + {!isPc ? ( +
+ {categoriesInMonth.map((c) => ( + setSelectedTaskListId(c.id)} + /> + ))} +
+ ) : null} + +
+ {filteredTasks.length === 0 ? ( +
+ 아직 완료된 작업이 없어요. +
+ 하나씩 완료해가며 히스토리를 만들어보세요! +
+ ) : null} + + {filteredTasks.length > 0 ? ( +
+ {tasksByDate.map((group) => ( +
+
+ + + {formatKoreanDateFromIso(`${group.dayKey}T00:00:00.000Z`)} + + +
+ +
+ {group.tasks.map((task) => ( +
{ + const t = e.target as HTMLElement | null; + if (isOpenDetailBlockedTarget(t)) return; + handleOpenDetail(task.id); + }} + > +
+ { + await apiToggleDone(task, checked); + }} + onKebabClick={() => + setOpenedTaskMenuId((prev) => + prev === task.id ? null : task.id, + ) + } + /> + + {openedTaskMenuId === task.id ? ( +
    +
  • + +
  • +
  • + +
  • +
+ ) : null} +
+
+ ))} +
+
+ ))} +
+ ) : null} +
+
+
+
+
+ + {/* Detail Overlay */} + {detailMounted && selectedTask ? ( +
+
e.stopPropagation()}> + { + await apiToggleDone(selectedTask, !selectedTask.doneAt); + }} + onEdit={() => {}} + onDelete={async () => { + await apiDelete(selectedTask); + closeDetail(); + }} + onClose={closeDetail} + onCommentSubmit={(content) => createComment.mutate({ content })} + /> +
+
+ ) : null} +
+
+ ); +}