From be06f14be62b01dac37b8cfd24d66fd98a521c09 Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Tue, 24 Feb 2026 21:09:48 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EB=AF=B8=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B8=B0=EB=8A=A5=20=EC=9D=BC=EB=B6=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyHistory/MyHistory.module.css} | 307 +++++----- .../components/MyHistory/MyHistory.tsx} | 553 ++++++++++-------- .../_domain/components/MyHistory}/queries.ts | 94 ++- 3 files changed, 488 insertions(+), 466 deletions(-) rename src/app/{(root)/history/history.module.css => [teamid]/_domain/components/MyHistory/MyHistory.module.css} (64%) rename src/app/{(root)/history/page.tsx => [teamid]/_domain/components/MyHistory/MyHistory.tsx} (58%) rename src/app/{(root)/history/hooks => [teamid]/_domain/components/MyHistory}/queries.ts (67%) diff --git a/src/app/(root)/history/history.module.css b/src/app/[teamid]/_domain/components/MyHistory/MyHistory.module.css similarity index 64% rename from src/app/(root)/history/history.module.css rename to src/app/[teamid]/_domain/components/MyHistory/MyHistory.module.css index ccdfb1f..ddb928d 100644 --- a/src/app/(root)/history/history.module.css +++ b/src/app/[teamid]/_domain/components/MyHistory/MyHistory.module.css @@ -12,69 +12,7 @@ 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; - } + flex-direction: column; } /* ===== main ===== */ @@ -83,14 +21,13 @@ display: flex; flex-direction: column; min-width: 0; - padding: 120px 85px 80px; + padding: 120px 67px 80px; box-sizing: border-box; } @media (max-width: 1023px) { .main { padding: 0; - padding-top: calc(60px + env(safe-area-inset-top)); } } @@ -99,7 +36,10 @@ width: 100%; } -/* ✅ 모바일 패딩 18px + margin-top 17px */ +.teamHeaderRow { + position: relative; +} + @media (max-width: 767px) { .teamHeaderWrap { margin-top: 17px; @@ -108,7 +48,6 @@ } } -/* ✅ 태블릿 패딩 26px + margin-top 32px */ @media (min-width: 768px) and (max-width: 1023px) { .teamHeaderWrap { margin-top: 32px; @@ -117,7 +56,6 @@ } } -/* ✅ PC에서는 TeamHeader 가운데 정렬만 */ @media (min-width: 1024px) { :global(.TeamHeader-module__H3kcRq__container) { width: 100%; @@ -127,6 +65,71 @@ } } +/* TeamHeader 톱니 메뉴 */ +.teamMenu { + position: absolute; + right: 0; + top: 52px; + width: 140px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); + z-index: 15000; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* 모바일/태블릿: top 대신 left:50px */ +@media (max-width: 1023px) { + .teamMenu { + top: auto; + right: auto; + left: 50px; + } +} + +/* 모바일: width 100px */ +@media (max-width: 743px) { + .teamMenu { + width: 100px; + } +} + +.teamMenuItem { + width: 100%; + height: 34px; + border: none; + background: transparent; + cursor: pointer; + text-align: center; + border-radius: 10px; + font-size: 14px; + color: #0f172a; +} + +.teamMenuItem:hover { + background: #f1f5f9; +} + +.teamMenuItemDanger { + width: 100%; + height: 34px; + border: none; + background: transparent; + cursor: pointer; + text-align: center; + border-radius: 10px; + font-size: 14px; + color: #ef4444; +} + +.teamMenuItemDanger:hover { + background: #fff1f2; +} + /* ===== body layout ===== */ .body { display: flex; @@ -137,14 +140,12 @@ gap: 48px; } -/* ✅ PC gap 76 */ @media (min-width: 1024px) { .body { gap: 76px; } } -/* 모바일/태블릿 */ @media (max-width: 743px) { .body { margin-top: 25px; @@ -154,6 +155,7 @@ gap: 18px; } } + @media (min-width: 744px) and (max-width: 1023px) { .body { margin-top: 25px; @@ -164,7 +166,7 @@ } } -/* ===== PC Left ===== */ +/* ===== Left (PC only) ===== */ .leftCol { width: 270px; flex-shrink: 0; @@ -172,30 +174,41 @@ flex-direction: column; } +@media (max-width: 1023px) { + .leftCol { + display: none; + } +} + +/* "내가 한 일" */ .leftTitle { - margin: 0 0 22px 0; - font-size: 18px; - font-weight: 800; + margin: 0; + font-size: 20px; + font-weight: 700; color: #0f172a; } +/* 스크롤 + margin-top 40 */ .leftScroll { max-height: 768px; overflow: auto; padding-right: 10px; box-sizing: border-box; + margin-top: 40px; } -.monthSection { - margin-bottom: 40px; +/* 월별 묶음 간격 55px */ +.monthBlock { + display: flex; + flex-direction: column; + gap: 17px; + margin-bottom: 55px; } -.monthTitle { - font-size: 14px; - font-weight: 800; +.monthBlockTitle { + font-size: 16px; + font-weight: 500; color: #0f172a; - margin-bottom: 12px; - cursor: pointer; } .cardStack { @@ -204,6 +217,12 @@ gap: 12px; } +.leftEmpty { + color: #94a3b8; + font-size: 13px; + padding: 10px 0; +} + /* ===== Right ===== */ .rightCol { min-width: 0; @@ -310,13 +329,6 @@ top: 0px; } -@media (min-width: 1024px) { - .calendarBtn { - right: 0px; - top: 0px; - } -} - .calendarPopover { position: absolute; top: calc(0px + 28px + 10px); @@ -336,16 +348,43 @@ } } -/* Chip row */ +/* 모바일/태블릿 칩 라인 (PC 숨김) */ .chipRow { - display: flex; - flex-wrap: wrap; - gap: 4px; - box-sizing: border-box; - margin-top: 27px; + display: none; + margin-top: 16px; + gap: 10px; + overflow-x: auto; + padding-bottom: 6px; + + width: 100%; + justify-content: center; } -/* Body */ +@media (max-width: 1023px) { + .chipRow { + display: flex; + } +} + +/* Chip 크기: 모바일 86px */ +@media (max-width: 743px) { + :global(.Chip-module__j4Ti-q__chip.Chip-module__j4Ti-q__small) { + width: 87px !important; + height: 33px !important; + padding: 0 !important; + border: 1px solid #e2e8f0 !important; + } +} +@media (min-width: 744px) and (max-width: 1023px) { + :global(.Chip-module__j4Ti-q__chip.Chip-module__j4Ti-q__small) { + width: 109px !important; + height: 43px !important; + padding: 0 14px !important; + border: 1px solid #e2e8f0 !important; + } +} + +/* body */ .boxBody { flex: 1; overflow: auto; @@ -404,16 +443,12 @@ 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; - } +/* 히스토리니까 체크박스 눌리지 않게 */ +.taskRow :global([role='checkbox']), +.taskRow :global(input[type='checkbox']), +.taskRow :global([aria-checked]) { + pointer-events: none !important; + cursor: default !important; } /* task kebab menu */ @@ -454,7 +489,13 @@ background: #f1f5f9; } -/* ===== Detail Overlay (history 원본 유지) ===== */ +.taskMenuItemDisabled { + pointer-events: none; + opacity: 0.45; + cursor: not-allowed; +} + +/* ===== Detail Overlay ===== */ .detailOverlay { position: fixed; top: 0; @@ -492,46 +533,6 @@ 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; @@ -559,11 +560,23 @@ } } +/* TaskDetailCard 제목 마진 0 */ :global(.TaskDetailCard-module__8btaCa__title) { margin: 0 !important; + padding-top: 72px !important; +} +@media (max-width: 1023px) { + :global(.TaskDetailCard-module__8btaCa__title) { + padding-top: 40px !important; + } +} +@media (max-width: 767px) { + :global(.TaskDetailCard-module__8btaCa__title) { + padding-top: 20px !important; + } } -/* ✅ 히스토리라서 디테일 내부 inverse FilledRoundButton 클릭 금지 */ +/* 히스토리라서 디테일 내부 inverse FilledRoundButton 클릭 금지 */ .detailOverlay :global( .ButtonBehavior-module__7wasrG__buttonBase.FilledRoundButton-module__5BYrQG__root.FilledRoundButton-module__5BYrQG__inverse.FilledRoundButton-module__5BYrQG__shadow diff --git a/src/app/(root)/history/page.tsx b/src/app/[teamid]/_domain/components/MyHistory/MyHistory.tsx similarity index 58% rename from src/app/(root)/history/page.tsx rename to src/app/[teamid]/_domain/components/MyHistory/MyHistory.tsx index 838210c..6af4eb8 100644 --- a/src/app/(root)/history/page.tsx +++ b/src/app/[teamid]/_domain/components/MyHistory/MyHistory.tsx @@ -1,30 +1,19 @@ 'use client'; -import { useEffect, useMemo, useRef, useState, type MouseEvent, useSyncExternalStore } from 'react'; +import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'; import Image from 'next/image'; +import { useParams, useRouter } from 'next/navigation'; 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 styles from './MyHistory.module.css'; 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 Chip from '@/components/Chip/Chip'; 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 { @@ -37,20 +26,8 @@ import { 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, - ); -} + deleteTeam, +} from '@/app/[teamid]/_domain/components/MyHistory/queries'; type Writer = { id: number; nickname: string; image: string | null }; type UiComment = { @@ -63,15 +40,28 @@ type UiComment = { user: Writer; }; +function pad2(n: number) { + return String(n).padStart(2, '0'); +} function ymFromDate(d: Date) { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`; +} +function parseYm(ym: string) { + const [y, m] = ym.split('-').map(Number); + return { y, m }; +} +function addMonths(ym: string, delta: number) { + const { y, m } = parseYm(ym); + const d = new Date(y, m - 1, 1, 0, 0, 0, 0); + d.setMonth(d.getMonth() + delta); + return ymFromDate(d); } function formatYearMonthFromKey(ym: string) { const [y, m] = ym.split('-'); return `${y}년 ${Number(m)}월`; } -function formatKoreanDateFromIso(iso: string) { - const d = new Date(iso); +function formatKoreanDateFromIso(isoLike: string) { + const d = new Date(isoLike); const yyyy = d.getFullYear(); const mm = d.getMonth() + 1; const dd = d.getDate(); @@ -85,13 +75,21 @@ function frequencyLabel(freq?: ApiFrequency | null) { return undefined; } function monthRangeIso(ym: string) { - const [y, m] = ym.split('-').map(Number); + const { y, m } = parseYm(ym); 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 + const to = new Date(y, m, 1, 0, 0, 0, 0); return { fromIso: from.toISOString(), toIso: to.toISOString() }; } +function dayKeyFromIso(iso: string) { + const d = new Date(iso); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} +function monthKeyFromIso(iso: string) { + const d = new Date(iso); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`; +} -/** ✅ 체크박스/케밥/버튼 클릭이면 디테일 오픈 금지 */ +/** 체크박스/케밥/버튼 클릭이면 디테일 오픈 금지 */ function isOpenDetailBlockedTarget(target: HTMLElement | null) { if (!target) return false; @@ -118,17 +116,11 @@ function isOpenDetailBlockedTarget(target: HTMLElement | null) { return false; } -export default function HistoryPage() { +export default function MyHistory() { 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); + const router = useRouter(); + const params = useParams<{ teamid?: string }>(); + const teamId = params?.teamid ?? ''; // ===== API ===== const { data: me } = useMe(); @@ -138,17 +130,12 @@ export default function HistoryPage() { 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 activeGroupId = groups[0]?.id ?? 0; 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); @@ -156,32 +143,48 @@ export default function HistoryPage() { // ===== 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]); + const { fromIso: selectedFromIso, toIso: selectedToIso } = useMemo( + () => monthRangeIso(selectedMonth), + [selectedMonth], + ); + + // ✅ 선택월 다음달 시작까지(=선택월 포함) 받아오기 + const earliestFromIso = '2000-01-01T00:00:00.000Z'; + const nextMonth = useMemo(() => addMonths(selectedMonth, 1), [selectedMonth]); + const { fromIso: toIso } = useMemo(() => monthRangeIso(nextMonth), [nextMonth]); - // ===== done tasks (모든 taskList를 한번에 모아오기) ===== + // ===== done tasks ===== const taskListIds = useMemo(() => taskLists.map((t) => t.id), [taskLists]); - const { tasksDoneAll } = useDoneTasksForTaskLists({ + + const { tasksDoneAll, isLoading: isDoneLoading } = useDoneTasksForTaskLists({ groupId: activeGroupId, taskListIds, - fromIso, + fromIso: earliestFromIso, toIso, }); - // ===== category selection (taskList) ===== - const [selectedTaskListId, setSelectedTaskListId] = useState(null); + // ===== 선택 월만 추리기 ===== + const selectedFromT = useMemo(() => new Date(selectedFromIso).getTime(), [selectedFromIso]); + const selectedToT = useMemo(() => new Date(selectedToIso).getTime(), [selectedToIso]); - // "기본 카테고리"는 파생값(첫 taskList), effect로 setState 하지 않음 - const effectiveTaskListId = selectedTaskListId ?? taskLists[0]?.id ?? null; + const tasksDoneSelectedMonth = useMemo(() => { + return tasksDoneAll.filter((t: DoneTask) => { + const iso = t.doneAt ?? t.date ?? ''; + if (!iso) return false; + const time = new Date(iso).getTime(); + return time >= selectedFromT && time < selectedToT; + }); + }, [tasksDoneAll, selectedFromT, selectedToT]); + + // ===== category selection ===== + const [selectedTaskListId, setSelectedTaskListId] = useState(null); - const categoriesInMonth = useMemo(() => { - // taskList별 완료개수 + const categoriesInSelectedMonth = useMemo(() => { const map = new Map(); - tasksDoneAll.forEach((t) => { + tasksDoneSelectedMonth.forEach((t: DoneTask) => { const id = (t.taskListId ?? 0) as number; if (!id) return; map.set(id, (map.get(id) ?? 0) + 1); @@ -192,35 +195,98 @@ export default function HistoryPage() { label: tl.name, count: map.get(tl.id) ?? 0, })); - }, [taskLists, tasksDoneAll]); + }, [taskLists, tasksDoneSelectedMonth]); + + const firstNonZeroTaskListId = useMemo(() => { + return categoriesInSelectedMonth.find((c) => c.count > 0)?.id ?? null; + }, [categoriesInSelectedMonth]); + + const fallbackFirstTaskListId = useMemo(() => taskLists[0]?.id ?? null, [taskLists]); + + const effectiveTaskListId = useMemo(() => { + if (selectedTaskListId != null && taskLists.some((t) => t.id === selectedTaskListId)) { + return selectedTaskListId; + } + return firstNonZeroTaskListId ?? fallbackFirstTaskListId; + }, [selectedTaskListId, taskLists, firstNonZeroTaskListId, fallbackFirstTaskListId]); - const filteredTasks = useMemo(() => { + const filteredTasks = useMemo(() => { if (!effectiveTaskListId) return []; - return tasksDoneAll.filter((t) => (t.taskListId ?? 0) === effectiveTaskListId); - }, [tasksDoneAll, effectiveTaskListId]); + return tasksDoneSelectedMonth.filter( + (t: DoneTask) => (t.taskListId ?? 0) === effectiveTaskListId, + ); + }, [tasksDoneSelectedMonth, effectiveTaskListId]); const tasksByDate = useMemo(() => { const map = new Map(); - filteredTasks.forEach((t) => { + + filteredTasks.forEach((t: DoneTask) => { const iso = t.doneAt ?? t.date ?? ''; - const dayKey = iso ? iso.slice(0, 10) : '1970-01-01'; + const dayKey = iso ? dayKeyFromIso(iso) : '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)! })); + const keys = Array.from(map.keys()).sort((a, b) => (a > b ? -1 : 1)); + + return keys.map((k) => { + const list = (map.get(k) ?? []).slice().sort((a, b) => { + const ai = a.doneAt ?? a.date ?? ''; + const bi = b.doneAt ?? b.date ?? ''; + return ai > bi ? -1 : 1; + }); + return { dayKey: k, tasks: list }; + }); }, [filteredTasks]); + // ✅ PC 왼쪽: “데이터 있는 달만” 표시 (선택월 이하만) + const leftMonthBlocks = useMemo(() => { + const monthMap = new Map>(); + + tasksDoneAll.forEach((t) => { + const iso = t.doneAt ?? t.date ?? ''; + const tlId = (t.taskListId ?? 0) as number; + if (!iso || !tlId) return; + + const mk = monthKeyFromIso(iso); + if (mk > selectedMonth) return; + + const inner = monthMap.get(mk) ?? new Map(); + inner.set(tlId, (inner.get(tlId) ?? 0) + 1); + monthMap.set(mk, inner); + }); + + const months = Array.from(monthMap.keys()).sort((a, b) => (a > b ? -1 : 1)); + + return months + .map((monthKey) => { + const inner = monthMap.get(monthKey) ?? new Map(); + const categories = taskLists.map((tl) => ({ + id: tl.id, + label: tl.name, + count: inner.get(tl.id) ?? 0, + })); + const total = categories.reduce((acc, c) => acc + c.count, 0); + + return { + monthKey, + monthLabel: formatYearMonthFromKey(monthKey), + categories, + total, + }; + }) + .filter((b) => b.total > 0); + }, [tasksDoneAll, taskLists, selectedMonth]); + // ===== UI states ===== const [openedTaskMenuId, setOpenedTaskMenuId] = useState(null); const [calendarOpen, setCalendarOpen] = useState(false); + const [teamMenuOpen, setTeamMenuOpen] = useState(false); // detail overlay const [detailMounted, setDetailMounted] = useState(false); const [detailOpen, setDetailOpen] = useState(false); - const [selectedTaskId, setSelectedTaskId] = useState(0); const effectiveSelectedTaskId = useMemo(() => { @@ -234,7 +300,6 @@ export default function HistoryPage() { return filteredTasks.find((t) => t.id === effectiveSelectedTaskId) ?? null; }, [filteredTasks, effectiveSelectedTaskId]); - // comments const { data: apiComments = [] } = useTaskComments(effectiveSelectedTaskId); const createComment = useCreateTaskComment(effectiveSelectedTaskId); @@ -277,6 +342,7 @@ export default function HistoryPage() { } const handleOpenDetail = (taskId: number) => { + setOpenedTaskMenuId(null); if (detailMounted && detailOpen && taskId === effectiveSelectedTaskId) { closeDetail(); return; @@ -285,9 +351,8 @@ export default function HistoryPage() { openDetail(); }; - const invalidateCurrentMonth = async () => { - // 월 범위/카테고리 쿼리키 전부 무효화 - await qc.invalidateQueries({ queryKey: ['doneTasks', activeGroupId] }); + const invalidateCurrentRange = async () => { + await qc.invalidateQueries({ queryKey: ['doneTasks'] }); }; async function apiToggleDone(task: DoneTask, done: boolean) { @@ -298,22 +363,59 @@ export default function HistoryPage() { taskId: task.id, done, }); - await invalidateCurrentMonth(); + await invalidateCurrentRange(); } async function apiDelete(task: DoneTask) { if (!activeGroupId || !task.taskListId) return; await deleteTask({ groupId: activeGroupId, taskListId: task.taskListId, taskId: task.id }); - await invalidateCurrentMonth(); + await invalidateCurrentRange(); } - // outside click close + const onPrevMonth = () => { + const prev = addMonths(selectedMonth, -1); + setUserSelectedMonth(prev); + setOpenedTaskMenuId(null); + setCalendarOpen(false); + setTeamMenuOpen(false); + closeDetailImmediate(); + }; + + const onNextMonth = () => { + const next = addMonths(selectedMonth, 1); + setUserSelectedMonth(next); + setOpenedTaskMenuId(null); + setCalendarOpen(false); + setTeamMenuOpen(false); + closeDetailImmediate(); + }; + + const monthLabel = formatYearMonthFromKey(selectedMonth); + + // TeamHeader settingsLink 클릭 / outside close + const lastTeamMenuToggleAt = useRef(0); + 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); + const settingsLink = t.closest( + '.TeamHeader-module__H3kcRq__settingsLink', + ) as HTMLElement | null; + if (settingsLink) { + ev.preventDefault?.(); + ev.stopPropagation?.(); + lastTeamMenuToggleAt.current = Date.now(); + setTeamMenuOpen((p) => !p); + return; + } + + const aria = (t.closest('[aria-label]')?.getAttribute('aria-label') ?? '').toLowerCase(); + const isKebabClick = aria.includes('더보기') || aria.includes('kebab'); + if (!isKebabClick && openedTaskMenuId !== null && !t.closest(`.${styles.taskMenu}`)) { + setOpenedTaskMenuId(null); + } if ( calendarOpen && @@ -322,167 +424,98 @@ export default function HistoryPage() { ) { setCalendarOpen(false); } + + if (teamMenuOpen) { + const justToggled = Date.now() - lastTeamMenuToggleAt.current < 120; + if (!justToggled && !t.closest(`.${styles.teamMenu}`)) { + setTeamMenuOpen(false); + } + } }; document.addEventListener('click', onDoc); return () => document.removeEventListener('click', onDoc); - }, [openedTaskMenuId, calendarOpen]); + }, [openedTaskMenuId, calendarOpen, teamMenuOpen]); - // handlers - const changeTeam = (groupId: number) => { - setUserSelectedGroupId(groupId); - - // 팀 바뀌면 선택 리셋 (effect로 setState 금지) - setSelectedTaskListId(null); - setOpenedTaskMenuId(null); - setCalendarOpen(false); - closeDetailImmediate(); + const goTeamEditPage = () => { + setTeamMenuOpen(false); + if (teamId) router.push(`/${teamId}/team`); + else router.push(`/team`); }; - const onPrevMonth = () => { - const base = new Date(`${selectedMonth}-01T00:00:00`); - base.setMonth(base.getMonth() - 1); - setUserSelectedMonth(ymFromDate(base)); - setOpenedTaskMenuId(null); - setCalendarOpen(false); - closeDetailImmediate(); + const doDeleteTeam = async () => { + setTeamMenuOpen(false); + const ok = window.confirm('팀을 삭제할까요? 삭제하면 되돌릴 수 없어요.'); + if (!ok) return; + try { + await deleteTeam(); + if (teamId) router.push(`/${teamId}`); + else router.push(`/`); + } catch { + alert('삭제에 실패했어요. (권한/로그인 상태를 확인해주세요)'); + } }; - const onNextMonth = () => { - const base = new Date(`${selectedMonth}-01T00:00:00`); - base.setMonth(base.getMonth() + 1); - setUserSelectedMonth(ymFromDate(base)); - setOpenedTaskMenuId(null); - setCalendarOpen(false); - closeDetailImmediate(); + const preventAll = (e: ReactMouseEvent | React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); }; - 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 */}
- +
+ + + {teamMenuOpen ? ( +
+ + +
+ ) : null} +
- {/* LEFT: PC만 - taskList를 카드로 */} - {isPc ? ( - - ) : null} + {/* LEFT (PC) */} + {/* RIGHT */}
@@ -528,39 +561,39 @@ export default function HistoryPage() { ) : null}
- {/* Mobile: taskList를 Chip으로 */} - {!isPc ? ( -
- {categoriesInMonth.map((c) => ( - setSelectedTaskListId(c.id)} - /> - ))} -
- ) : null} + {/* 모바일/태블릿 Chip row: 항상 렌더 (count=0도 표시) */} +
+ {categoriesInSelectedMonth.map((c) => ( + { + setSelectedTaskListId(c.id); + setOpenedTaskMenuId(null); + closeDetailImmediate(); + }} + /> + ))} +
- {filteredTasks.length === 0 ? ( -
- 아직 완료된 작업이 없어요. -
- 하나씩 완료해가며 히스토리를 만들어보세요! -
+ {isDoneLoading ?
불러오는 중…
: null} + + {!isDoneLoading && filteredTasks.length === 0 ? ( +
이 달에 완료된 작업이 없어요.
) : null} - {filteredTasks.length > 0 ? ( + {!isDoneLoading && filteredTasks.length > 0 ? (
{tasksByDate.map((group) => (
- {formatKoreanDateFromIso(`${group.dayKey}T00:00:00.000Z`)} + {formatKoreanDateFromIso(`${group.dayKey}T00:00:00`)}
@@ -570,7 +603,7 @@ export default function HistoryPage() {
{ + onClick={(e: ReactMouseEvent) => { const t = e.target as HTMLElement | null; if (isOpenDetailBlockedTarget(t)) return; handleOpenDetail(task.id); @@ -579,14 +612,12 @@ export default function HistoryPage() {
{ - await apiToggleDone(task, checked); - }} + onCheckedChange={undefined} onKebabClick={() => setOpenedTaskMenuId((prev) => prev === task.id ? null : task.id, @@ -599,12 +630,14 @@ export default function HistoryPage() { className={styles.taskMenu} role="menu" aria-label="할 일 메뉴" + onClick={(e) => e.stopPropagation()} >
  • +
  • @@ -673,7 +705,10 @@ export default function HistoryPage() { closeDetail(); }} onClose={closeDetail} - onCommentSubmit={(content) => createComment.mutate({ content })} + onCommentSubmit={(content) => { + if (!effectiveSelectedTaskId) return; + createComment.mutate({ content }); + }} />
  • diff --git a/src/app/(root)/history/hooks/queries.ts b/src/app/[teamid]/_domain/components/MyHistory/queries.ts similarity index 67% rename from src/app/(root)/history/hooks/queries.ts rename to src/app/[teamid]/_domain/components/MyHistory/queries.ts index 5a148ff..3b25af7 100644 --- a/src/app/(root)/history/hooks/queries.ts +++ b/src/app/[teamid]/_domain/components/MyHistory/queries.ts @@ -1,19 +1,27 @@ import { useQueries, useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; /** - * ✅ BFF 프록시 기반 (httpOnly cookie 인증) + * BFF 프록시 기반 (httpOnly cookie 인증) */ function proxyUrl(path: string) { const p = path.startsWith('/') ? path.slice(1) : path; return `/api/proxy/${p}`; } +class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + 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}`); + throw new ApiError(res.status, text || `Request failed: ${res.status}`); } if (res.status === 204) return undefined as T; @@ -56,7 +64,6 @@ export type DoneTask = { name: string; description?: string | null; - // doneAt(완료시각) / date(할일 날짜) 둘중 하나는 있다고 가정 doneAt?: string | null; date?: string | null; @@ -69,7 +76,6 @@ export type DoneTask = { image: string | null; } | null; - // taskListId 필수로 붙여서 내려오는 케이스가 많음 (없으면 우리가 주입) taskListId?: number | null; }; @@ -94,69 +100,30 @@ export type ApiComment = { export function useMe() { return useQuery({ queryKey: ['me'], - queryFn: () => apiFetch('user/me'), + queryFn: async () => { + return await apiFetch('user/me'); + }, staleTime: 60_000, }); } -/** ✅ 그룹 상세 (taskLists) */ +/** 그룹 상세 (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 }; + if (!Number.isFinite(groupId) || groupId <= 0) { + throw new ApiError(400, 'Invalid groupId'); + } + return await apiFetch(`groups/${groupId}`); }, - enabled: - Number.isFinite(groupId) && - groupId > 0 && - Number.isFinite(taskListId) && - taskListId > 0 && - !!fromIso && - !!toIso, - staleTime: 10_000, + enabled: Number.isFinite(groupId) && groupId > 0, + staleTime: 30_000, }); } /** - * ✅ 여러 taskList의 done tasks를 한 번에 모으는 Hook - * - rules-of-hooks 위반 없이, useQueries는 컴포넌트 최상단에서만 1번 호출. + * 여러 taskList의 done tasks를 한 번에 모으는 Hook */ export function useDoneTasksForTaskLists(params: { groupId: number; @@ -170,15 +137,14 @@ export function useDoneTasksForTaskLists(params: { 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 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: @@ -194,7 +160,6 @@ export function useDoneTasksForTaskLists(params: { 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 }; @@ -205,7 +170,9 @@ export function useDoneTasksForTaskLists(params: { export function useTaskComments(taskId: number) { return useQuery({ queryKey: ['taskComments', taskId], - queryFn: () => apiFetch(`tasks/${taskId}/comments`), + queryFn: async () => { + return await apiFetch(`tasks/${taskId}/comments`); + }, enabled: Number.isFinite(taskId) && taskId > 0, }); } @@ -225,7 +192,7 @@ export function useCreateTaskComment(taskId: number) { }); } -// ===== Direct mutations for history (hook in map 금지 회피) ===== +// ===== Direct mutations for history ===== export async function patchTaskDone(params: { groupId: number; @@ -249,3 +216,10 @@ export async function deleteTask(params: { groupId: number; taskListId: number; method: 'DELETE', }); } + +/** + * 팀 삭제 (프로젝트 엔드포인트 맞게 필요하면 변경) + */ +export async function deleteTeam() { + return apiFetch('user', { method: 'DELETE' }); +}