From d18e504fceec11bb83d1f88e7b6539a3126e6565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=8C=EB=A7=9D?= Date: Thu, 26 Feb 2026 04:15:42 +0900 Subject: [PATCH] =?UTF-8?q?style:=20myhistory=20=EB=A7=88=EC=A7=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/myhistory/page.module.css | 593 +++++++++++++++++++ src/app/(root)/myhistory/page.tsx | 723 +++++++++++++++++++++++ src/app/(root)/myhistory/queries.ts | 225 +++++++ 3 files changed, 1541 insertions(+) create mode 100644 src/app/(root)/myhistory/page.module.css create mode 100644 src/app/(root)/myhistory/page.tsx create 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 new file mode 100644 index 0000000..46a9dfc --- /dev/null +++ b/src/app/(root)/myhistory/page.module.css @@ -0,0 +1,593 @@ +: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 (min-width: 1200px) { + .main { + margin-left: 198px; + } +} + +@media (max-width: 1199px) { + .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: 1199px) { + .teamHeaderWrap { + margin-top: 32px; + padding: 0 26px; + box-sizing: border-box; + } +} + +@media (min-width: 1200px) { + :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: 1199px) { + .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: 1200px) { + .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: 1199px) { + .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: 1199px) { + .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: 1199px) { + .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: 1200px) { + .whiteBox { + width: 768px; + } +} +@media (max-width: 1199px) { + .whiteBox { + width: 100%; + } +} +@media (min-width: 744px) and (max-width: 1199px) { + .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: 1199px) { + .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: 1200px) { + .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: 1199px) { + .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: 1199px) { + :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: 1199px) { + :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 new file mode 100644 index 0000000..899e347 --- /dev/null +++ b/src/app/(root)/myhistory/page.tsx @@ -0,0 +1,723 @@ +'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(); + useEffect(() => { + if (me) { + qc.setQueryData(['currentUser'], me); + } + }, [me, qc]); + + 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 new file mode 100644 index 0000000..3b25af7 --- /dev/null +++ b/src/app/(root)/myhistory/queries.ts @@ -0,0 +1,225 @@ +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' }); +}