From 15cc4c5f9261160e974945637b9b1fa0f858dce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Fri, 27 Feb 2026 21:22:34 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=EC=8B=9C=20=ED=83=84=EC=8A=A4=ED=83=9D=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/layout.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/(root)/layout.tsx b/src/app/(root)/layout.tsx index e32fcef..c4a13f8 100644 --- a/src/app/(root)/layout.tsx +++ b/src/app/(root)/layout.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import { usePathname, useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { Sidebar, MobileHeader } from '@/components/sidebar'; import TeamSidebarDropdown from './[teamid]/_domain/components/Team/TeamSidebarDropdown'; @@ -11,6 +12,7 @@ import styles from './layout.module.css'; export default function RootLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const router = useRouter(); + const queryClient = useQueryClient(); const { data: user, isPending } = useCurrentUser(); // isPending: 최초 로딩 중 (undefined) @@ -35,6 +37,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); + queryClient.clear(); router.push('/login'); }; From 5b3e6104da98eddd95e38649ab19da648e1e667d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Fri, 27 Feb 2026 21:42:16 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20myhistory=20=EB=8C=80=EC=86=8C?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=EC=A4=91=EB=B3=B5=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/Myhistory/page.module.css | 587 ------------------ src/app/(root)/Myhistory/page.tsx | 719 ----------------------- src/app/(root)/Myhistory/queries.ts | 225 ------- 3 files changed, 1531 deletions(-) delete mode 100644 src/app/(root)/Myhistory/page.module.css delete mode 100644 src/app/(root)/Myhistory/page.tsx delete mode 100644 src/app/(root)/Myhistory/queries.ts diff --git a/src/app/(root)/Myhistory/page.module.css b/src/app/(root)/Myhistory/page.module.css deleted file mode 100644 index 38ab16b..0000000 --- a/src/app/(root)/Myhistory/page.module.css +++ /dev/null @@ -1,587 +0,0 @@ -: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; - flex-direction: column; -} - -/* ===== main ===== */ -.main { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - padding: 90px 91px 110px 91px; - box-sizing: border-box; -} - -@media (max-width: 1023px) { - .main { - padding: 0; - } -} - -/* TeamHeader wrap */ -.teamHeaderWrap { - width: 100%; -} - -.teamHeaderRow { - position: relative; -} - -@media (max-width: 767px) { - .teamHeaderWrap { - margin-top: 17px; - padding: 0 18px; - box-sizing: border-box; - } -} - -@media (min-width: 768px) and (max-width: 1023px) { - .teamHeaderWrap { - margin-top: 32px; - padding: 0 26px; - box-sizing: border-box; - } -} - -@media (min-width: 1024px) { - :global(.TeamHeader-module__H3kcRq__container) { - width: 100%; - max-width: 1200px; - margin-left: auto !important; - margin-right: auto !important; - } -} - -/* 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; - align-items: flex-start; - justify-content: center; - min-width: 0; - margin-top: 46px; - gap: 48px; -} - -@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; - } -} - -/* ===== Left (PC only) ===== */ -.leftCol { - width: 270px; - flex-shrink: 0; - display: flex; - flex-direction: column; -} - -@media (max-width: 1023px) { - .leftCol { - display: none; - } -} - -/* "내가 한 일" */ -.leftTitle { - 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; -} - -/* 월별 묶음 간격 55px */ -.monthBlock { - display: flex; - flex-direction: column; - gap: 17px; - margin-bottom: 55px; -} - -.monthBlockTitle { - font-size: 16px; - font-weight: 500; - color: #0f172a; -} - -.cardStack { - display: flex; - flex-direction: column; - gap: 12px; -} - -.leftEmpty { - color: #94a3b8; - font-size: 13px; - padding: 10px 0; -} - -/* ===== 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; -} - -.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; - } -} - -/* 모바일/태블릿 칩 라인 (PC 숨김) */ -.chipRow { - display: none; - margin-top: 16px; - gap: 10px; - overflow-x: auto; - padding-bottom: 6px; - - width: 100%; - justify-content: center; -} - -@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: 86px !important; - height: 33px !important; - padding: 0 12px !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; - 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; -} - -/* 히스토리니까 체크박스 눌리지 않게 */ -.taskRow :global([role='checkbox']), -.taskRow :global(input[type='checkbox']), -.taskRow :global([aria-checked]) { - pointer-events: none !important; - cursor: default !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; -} - -.taskMenuItemDisabled { - pointer-events: none; - opacity: 0.45; - cursor: not-allowed; -} - -/* ===== Detail Overlay ===== */ -.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; -} - -@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; - } -} - -/* 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 클릭 금지 */ -.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)/Myhistory/page.tsx b/src/app/(root)/Myhistory/page.tsx deleted file mode 100644 index 9e9d2fd..0000000 --- a/src/app/(root)/Myhistory/page.tsx +++ /dev/null @@ -1,719 +0,0 @@ -'use client'; - -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 './page.module.css'; - -import TeamHeader from '@/components/team-header'; -import ArrowButton from '@/components/Button/domain/ArrowButton/ArrowButton'; -import TaskCard from '@/components/Card/TaskCard/TaskCard'; -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 calendarIcon from '@/assets/icons/calender/calenderSmall.svg'; - -import { - type ApiFrequency, - type DoneTask, - useCreateTaskComment, - useDoneTasksForTaskLists, - useGroupDetail, - useMe, - useTaskComments, - patchTaskDone, - deleteTask, - deleteTeam, -} from './queries'; - -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 pad2(n: number) { - return String(n).padStart(2, '0'); -} -function ymFromDate(d: Date) { - 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(isoLike: string) { - const d = new Date(isoLike); - 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 } = 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); - 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; - - 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 MyHistory() { - const qc = useQueryClient(); - const router = useRouter(); - const params = useParams<{ teamid?: string }>(); - const teamId = params?.teamid ?? ''; - - // ===== 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]); - - const activeGroupId = groups[0]?.id ?? 0; - const activeGroup = useMemo( - () => groups.find((g) => g.id === activeGroupId) ?? null, - [groups, activeGroupId], - ); - - 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); - const defaultMonth = useMemo(() => ymFromDate(new Date()), []); - const selectedMonth = userSelectedMonth ?? defaultMonth; - - 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 ===== - const taskListIds = useMemo(() => taskLists.map((t) => t.id), [taskLists]); - - const { tasksDoneAll, isLoading: isDoneLoading } = useDoneTasksForTaskLists({ - groupId: activeGroupId, - taskListIds, - fromIso: earliestFromIso, - toIso, - }); - - // ===== 선택 월만 추리기 ===== - const selectedFromT = useMemo(() => new Date(selectedFromIso).getTime(), [selectedFromIso]); - const selectedToT = useMemo(() => new Date(selectedToIso).getTime(), [selectedToIso]); - - 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 categoriesInSelectedMonth = useMemo(() => { - const map = new Map(); - tasksDoneSelectedMonth.forEach((t: DoneTask) => { - 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, 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(() => { - if (!effectiveTaskListId) return []; - return tasksDoneSelectedMonth.filter( - (t: DoneTask) => (t.taskListId ?? 0) === effectiveTaskListId, - ); - }, [tasksDoneSelectedMonth, effectiveTaskListId]); - - const tasksByDate = useMemo(() => { - const map = new Map(); - - filteredTasks.forEach((t: DoneTask) => { - const iso = t.doneAt ?? t.date ?? ''; - 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) => { - 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(() => { - 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]); - - 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) => { - setOpenedTaskMenuId(null); - if (detailMounted && detailOpen && taskId === effectiveSelectedTaskId) { - closeDetail(); - return; - } - setSelectedTaskId(taskId); - openDetail(); - }; - - const invalidateCurrentRange = async () => { - await qc.invalidateQueries({ queryKey: ['doneTasks'] }); - }; - - async function apiToggleDone(task: DoneTask, done: boolean) { - if (!activeGroupId || !task.taskListId) return; - await patchTaskDone({ - groupId: activeGroupId, - taskListId: task.taskListId, - taskId: task.id, - done, - }); - await invalidateCurrentRange(); - } - - async function apiDelete(task: DoneTask) { - if (!activeGroupId || !task.taskListId) return; - await deleteTask({ groupId: activeGroupId, taskListId: task.taskListId, taskId: task.id }); - await invalidateCurrentRange(); - } - - 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; - - 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 && - !t.closest(`.${styles.calendarPopover}`) && - !t.closest(`.${styles.calendarBtn}`) - ) { - 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, teamMenuOpen]); - - const goTeamEditPage = () => { - setTeamMenuOpen(false); - if (teamId) router.push(`/${teamId}/team`); - else router.push(`/team`); - }; - - 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 preventAll = (e: ReactMouseEvent | React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - }; - - return ( -
-
-
-
- - - {teamMenuOpen ? ( -
- - -
- ) : null} -
-
- -
- {/* LEFT (PC) */} - - - {/* RIGHT */} -
-
-
-
-
- - {monthLabel} - -
- - - - {calendarOpen ? ( -
e.stopPropagation()} - role="dialog" - aria-label="캘린더" - > - { - if (!d) return; - setUserSelectedMonth(ymFromDate(d)); - setCalendarOpen(false); - setOpenedTaskMenuId(null); - closeDetailImmediate(); - }} - /> -
- ) : null} -
- - {/* 모바일/태블릿 Chip row: 항상 렌더 (count=0도 표시) */} -
- {categoriesInSelectedMonth.map((c) => ( - { - setSelectedTaskListId(c.id); - setOpenedTaskMenuId(null); - closeDetailImmediate(); - }} - /> - ))} -
- -
- {isDoneLoading ?
불러오는 중…
: null} - - {!isDoneLoading && filteredTasks.length === 0 ? ( -
이 달에 완료된 작업이 없어요.
- ) : null} - - {!isDoneLoading && filteredTasks.length > 0 ? ( -
- {tasksByDate.map((group) => ( -
-
- - - {formatKoreanDateFromIso(`${group.dayKey}T00:00:00`)} - - -
- -
- {group.tasks.map((task) => ( -
{ - const t = e.target as HTMLElement | null; - if (isOpenDetailBlockedTarget(t)) return; - handleOpenDetail(task.id); - }} - > -
- - setOpenedTaskMenuId((prev) => - prev === task.id ? null : task.id, - ) - } - /> - - {openedTaskMenuId === task.id ? ( -
    e.stopPropagation()} - > -
  • - -
  • - -
  • - -
  • -
- ) : null} -
-
- ))} -
-
- ))} -
- ) : null} -
-
-
-
-
- - {/* Detail Overlay */} - {detailMounted && selectedTask ? ( -
-
e.stopPropagation()}> - { - await apiToggleDone(selectedTask, !selectedTask.doneAt); - }} - onEdit={() => {}} - onDelete={async () => { - await apiDelete(selectedTask); - closeDetail(); - }} - onClose={closeDetail} - onCommentSubmit={(content) => { - if (!effectiveSelectedTaskId) return; - createComment.mutate({ content }); - }} - /> -
-
- ) : null} -
-
- ); -} diff --git a/src/app/(root)/Myhistory/queries.ts b/src/app/(root)/Myhistory/queries.ts deleted file mode 100644 index 3b25af7..0000000 --- a/src/app/(root)/Myhistory/queries.ts +++ /dev/null @@ -1,225 +0,0 @@ -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}`; -} - -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 ApiError(res.status, 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?: string | null; - date?: string | null; - - commentCount?: number | null; - frequency?: ApiFrequency | null; - - writer?: { - id: number; - nickname: string; - image: string | null; - } | null; - - 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: async () => { - return await apiFetch('user/me'); - }, - staleTime: 60_000, - }); -} - -/** 그룹 상세 (taskLists) */ -export function useGroupDetail(groupId: number) { - return useQuery({ - queryKey: ['groupDetail', groupId], - queryFn: async () => { - if (!Number.isFinite(groupId) || groupId <= 0) { - throw new ApiError(400, 'Invalid groupId'); - } - return await apiFetch(`groups/${groupId}`); - }, - enabled: Number.isFinite(groupId) && groupId > 0, - staleTime: 30_000, - }); -} - -/** - * 여러 taskList의 done tasks를 한 번에 모으는 Hook - */ -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: async () => { - return await 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 ===== - -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', - }); -} - -/** - * 팀 삭제 (프로젝트 엔드포인트 맞게 필요하면 변경) - */ -export async function deleteTeam() { - return apiFetch('user', { method: 'DELETE' }); -} From 19f80ec3f519bea12084a27dc6a2e929245637fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Fri, 27 Feb 2026 21:46:06 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=20api=ED=98=B8=EC=B6=9C=20=EC=8B=A4=ED=8C=A8=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/layout.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/(root)/layout.tsx b/src/app/(root)/layout.tsx index bd1b661..e309d73 100644 --- a/src/app/(root)/layout.tsx +++ b/src/app/(root)/layout.tsx @@ -36,7 +36,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) }; const handleLogout = async () => { - await fetch('/api/auth/logout', { method: 'POST' }); + try { + await fetch('/api/auth/logout', { method: 'POST' }); + } catch (error) { + console.error('로그아웃 API 호출 실패:', error); + } queryClient.clear(); router.push('/login'); };