From 7d766cc1ecc4652066b549a925135e1db6e47161 Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Wed, 25 Feb 2026 02:54:39 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EA=B2=BD=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=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=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/TaskList/TaskList.module.css} | 9 +- .../_domain/components/TaskList/TaskList.tsx} | 598 ++++++------------ .../_domain/components/TaskList}/queries.ts | 198 ++++-- src/app/(root)/[teamid]/tasklist/page.tsx | 17 + 4 files changed, 334 insertions(+), 488 deletions(-) rename src/app/(root)/{list/list.module.css => [teamid]/_domain/components/TaskList/TaskList.module.css} (98%) rename src/app/(root)/{list/page.tsx => [teamid]/_domain/components/TaskList/TaskList.tsx} (77%) rename src/app/(root)/{list/hooks => [teamid]/_domain/components/TaskList}/queries.ts (62%) create mode 100644 src/app/(root)/[teamid]/tasklist/page.tsx diff --git a/src/app/(root)/list/list.module.css b/src/app/(root)/[teamid]/_domain/components/TaskList/TaskList.module.css similarity index 98% rename from src/app/(root)/list/list.module.css rename to src/app/(root)/[teamid]/_domain/components/TaskList/TaskList.module.css index 8d3e0da..21da7d3 100644 --- a/src/app/(root)/list/list.module.css +++ b/src/app/(root)/[teamid]/_domain/components/TaskList/TaskList.module.css @@ -1,6 +1,3 @@ -:global(html) { - height: 100%; -} :global(body) { height: 100%; min-height: 100dvh; @@ -14,7 +11,7 @@ overflow-x: hidden; } -/* ✅ TeamHeader 패딩 wrapper (모바일/태블릿만) */ +/* TeamHeader 패딩 wrapper (모바일/태블릿만) */ .teamHeaderPad { padding: 0; } @@ -106,7 +103,7 @@ flex: 1; display: flex; justify-content: center; - padding: 120px 85px 80px; + padding: 120px 85px 80px 0px; background: var(--color-background-secondary, #f5f6f8); box-sizing: border-box; min-height: 100dvh; @@ -117,7 +114,6 @@ .mainContents { width: 100%; padding: 0; - padding-top: calc(60px + env(safe-area-inset-top)); box-sizing: border-box; } } @@ -155,6 +151,7 @@ display: flex; flex-direction: column; gap: 12px; + cursor: pointer; } .todoCardWrap { diff --git a/src/app/(root)/list/page.tsx b/src/app/(root)/[teamid]/_domain/components/TaskList/TaskList.tsx similarity index 77% rename from src/app/(root)/list/page.tsx rename to src/app/(root)/[teamid]/_domain/components/TaskList/TaskList.tsx index 503701f..8655454 100644 --- a/src/app/(root)/list/page.tsx +++ b/src/app/(root)/[teamid]/_domain/components/TaskList/TaskList.tsx @@ -1,69 +1,47 @@ 'use client'; -import { - Suspense, - useEffect, - useMemo, - useRef, - useState, - type MouseEvent as ReactMouseEvent, -} from 'react'; +import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'; import Image from 'next/image'; import { useQueryClient } from '@tanstack/react-query'; -import { useRouter } from 'next/navigation'; -import { useInitialGroupId } from './_hooks/useInitialGroupId'; - -import styles from './list.module.css'; +import { useParams, useRouter } from 'next/navigation'; -import Sidebar from '@/components/sidebar/Sidebar'; -import TeamHeader from '@/components/team-header'; -import MobileHeader from '@/components/sidebar/MobileHeader'; -import MobileDrawer from '@/components/sidebar/MobileDrawer'; +import styles from './TaskList.module.css'; -import SidebarButton from '@/components/sidebar/SidebarButton'; -import SidebarAddButton from '@/components/sidebar/SidebarAddButton'; -import SidebarTeamSelect from '@/components/sidebar/SidebarTeamSelect'; +import { + type ApiFrequency, + type Task, + useCreateTask, + useCreateTaskComment, + useCreateTaskList, + useDeleteTask, + useDeleteTaskList, + useGroupDetail, + useMe, + usePatchTask, + useTaskComments, + useTaskListByDate, + useUpdateTaskList, +} from './queries'; +import TeamHeader from '@/components/team-header'; import WeekDateBar from '@/components/calendar/CalendarButton/WeekDateBar'; - import TaskListItem from '@/components/list/TaskListItem'; import FloatingButton from '@/components/Button/domain/FloatingButton/FloatingButton'; import ArrowButton from '@/components/Button/domain/ArrowButton/ArrowButton'; - import TodoCard from '@/components/todo-card/TodoCard'; import type { TodoItem } from '@/components/todo-card/types/types'; - import GnbAddButton from '@/components/Button/domain/GnbAddButton/GnbAddButton'; - import calendarIcon from '@/assets/icons/calender/calenderSmall.svg'; -import chessSmall from '@/assets/icons/chess/chessSmall.svg'; -import boardSmall from '@/assets/icons/board/boardSmall.svg'; import downArrowSmall from '@/assets/icons/arrow/downArrowSmall.svg'; - import TaskDetailCard from '@/components/Card/TaskDetailCard/TaskDetailCard'; - import CalenderModal from '@/components/Modal/domain/components/Calender/CalenderModal'; import type { CalenderModalSubmitPayload } from '@/components/Modal/domain/components/Calender/types/CalenderModal.types'; - import AddTodoList from '@/components/Modal/domain/components/AddTodoList/AddTodoList'; - import Calendar from '@/components/calendar/Calendar'; -import { - type ApiFrequency, - type Task, - useCreateTask, - useCreateTaskComment, - useCreateTaskList, - useDeleteTask, - useDeleteTaskList, - useGroupDetail, - useMe, - usePatchTask, - useTaskComments, - useTaskListByDate, - useUpdateTaskList, -} from '@/app/(root)/list/hooks/queries'; +/* =========================p + helpers + ========================= */ function isOpenDetailBlockedTarget(target: HTMLElement | null) { if (!target) return false; @@ -81,9 +59,8 @@ function isOpenDetailBlockedTarget(target: HTMLElement | null) { v.includes('더보기') || v.includes('kebab') || v.includes('케밥') - ) { + ) return true; - } } const cls = (target.className ?? '').toString().toLowerCase(); @@ -120,11 +97,17 @@ function isKebabTrigger(target: HTMLElement | null) { function formatYearMonth(date: Date) { return `${date.getFullYear()}년 ${date.getMonth() + 1}월`; } -function addMonths(base: Date, diff: number) { + +// ✅ 주 단위 이동용 +function addDays(base: Date, diff: number) { const d = new Date(base); - d.setMonth(d.getMonth() + diff); + d.setDate(d.getDate() + diff); return d; } +function addWeeks(base: Date, diff: number) { + return addDays(base, diff * 7); +} + function toDateKey(d: Date) { const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, '0'); @@ -231,70 +214,66 @@ type TodoCardData = { items: TodoItem[]; }; -type ApiWeekDay = - | 'MONDAY' - | 'TUESDAY' - | 'WEDNESDAY' - | 'THURSDAY' - | 'FRIDAY' - | 'SATURDAY' - | 'SUNDAY'; - -function toApiWeekDays(input: unknown): ApiWeekDay[] | undefined { +/** + * ✅ 서버 스웨거: weekDays는 string[]이 아니라 number[] + * Monday=0 ~ Sunday=6 로 보냄 + */ +type ApiWeekDayNum = 0 | 1 | 2 | 3 | 4 | 5 | 6; +function toApiWeekDayNums(input: unknown): ApiWeekDayNum[] | undefined { if (!Array.isArray(input)) return undefined; - const map: Record = { - mon: 'MONDAY', - monday: 'MONDAY', - '1': 'MONDAY', - 월: 'MONDAY', - 월요일: 'MONDAY', - - tue: 'TUESDAY', - tuesday: 'TUESDAY', - '2': 'TUESDAY', - 화: 'TUESDAY', - 화요일: 'TUESDAY', - - wed: 'WEDNESDAY', - wednesday: 'WEDNESDAY', - '3': 'WEDNESDAY', - 수: 'WEDNESDAY', - 수요일: 'WEDNESDAY', - - thu: 'THURSDAY', - thursday: 'THURSDAY', - '4': 'THURSDAY', - 목: 'THURSDAY', - 목요일: 'THURSDAY', - - fri: 'FRIDAY', - friday: 'FRIDAY', - '5': 'FRIDAY', - 금: 'FRIDAY', - 금요일: 'FRIDAY', - - sat: 'SATURDAY', - saturday: 'SATURDAY', - '6': 'SATURDAY', - 토: 'SATURDAY', - 토요일: 'SATURDAY', - - sun: 'SUNDAY', - sunday: 'SUNDAY', - '0': 'SUNDAY', - '7': 'SUNDAY', - 일: 'SUNDAY', - 일요일: 'SUNDAY', + const map: Record = { + mon: 0, + monday: 0, + 월: 0, + 월요일: 0, + '1': 0, + + tue: 1, + tuesday: 1, + 화: 1, + 화요일: 1, + '2': 1, + + wed: 2, + wednesday: 2, + 수: 2, + 수요일: 2, + '3': 2, + + thu: 3, + thursday: 3, + 목: 3, + 목요일: 3, + '4': 3, + + fri: 4, + friday: 4, + 금: 4, + 금요일: 4, + '5': 4, + + sat: 5, + saturday: 5, + 토: 5, + 토요일: 5, + '6': 5, + + sun: 6, + sunday: 6, + 일: 6, + 일요일: 6, + '0': 6, + '7': 6, }; - const out: ApiWeekDay[] = []; + const out: ApiWeekDayNum[] = []; for (const v of input) { if (typeof v !== 'string' && typeof v !== 'number') continue; const s = String(v).trim(); const lower = s.toLowerCase(); const mapped = map[lower] ?? map[s]; - if (mapped && !out.includes(mapped)) out.push(mapped); + if (mapped !== undefined && !out.includes(mapped)) out.push(mapped); } return out.length > 0 ? out : undefined; } @@ -317,31 +296,22 @@ function normalizeFreq(v: unknown): ApiFrequency { function getTaskFrequency(task: Task | null | undefined): ApiFrequency { const t = task as unknown as - | { - frequency?: unknown; - frequencyType?: unknown; - repeatType?: unknown; - } + | { frequency?: unknown; frequencyType?: unknown; repeatType?: unknown } | null | undefined; return normalizeFreq(t?.frequencyType ?? t?.frequency ?? t?.repeatType); } -function getTaskWeekDays(task: Task | null | undefined): ApiWeekDay[] | undefined { +function getTaskWeekDays(task: Task | null | undefined): number[] | undefined { const t = task as unknown as - | { - weekDays?: ApiWeekDay[]; - repeatDays?: unknown; - repeatWeekDays?: ApiWeekDay[]; - } + | { weekDays?: number[]; repeatWeekDays?: number[]; repeatDays?: unknown } | null | undefined; if (Array.isArray(t?.weekDays) && t?.weekDays.length) return t.weekDays; if (Array.isArray(t?.repeatWeekDays) && t?.repeatWeekDays.length) return t.repeatWeekDays; - const fromModalLike = toApiWeekDays(t?.repeatDays); - return fromModalLike; + return toApiWeekDayNums(t?.repeatDays); } function getTaskMonthDay(task: Task | null | undefined): number | undefined { @@ -356,13 +326,13 @@ function normalizeIsoForUi(isoLike: string, fallbackDateKey: string) { return raw; } -function weekDayKor(d: ApiWeekDay) { - if (d === 'MONDAY') return '월'; - if (d === 'TUESDAY') return '화'; - if (d === 'WEDNESDAY') return '수'; - if (d === 'THURSDAY') return '목'; - if (d === 'FRIDAY') return '금'; - if (d === 'SATURDAY') return '토'; +function weekDayKorNum(d: number) { + if (d === 0) return '월'; + if (d === 1) return '화'; + if (d === 2) return '수'; + if (d === 3) return '목'; + if (d === 4) return '금'; + if (d === 5) return '토'; return '일'; } @@ -373,7 +343,7 @@ function frequencyLabelFromTask(task: Task | null | undefined, fallbackDateKey: if (freq === 'WEEKLY') { const days = getTaskWeekDays(task); - if (days && days.length > 0) return `매주 반복(${days.map(weekDayKor).join(',')})`; + if (days && days.length > 0) return `매주 반복(${days.map(weekDayKorNum).join(',')})`; return '매주 반복'; } @@ -390,59 +360,32 @@ function frequencyLabelFromTask(task: Task | null | undefined, fallbackDateKey: return undefined; } -function toModalRepeatDays(days?: ApiWeekDay[]) { +function toModalRepeatDays(days?: number[]) { if (!days || days.length === 0) return []; - const map: Record = { - MONDAY: 'mon', - TUESDAY: 'tue', - WEDNESDAY: 'wed', - THURSDAY: 'thu', - FRIDAY: 'fri', - SATURDAY: 'sat', - SUNDAY: 'sun', + const map: Record = { + 0: 'mon', + 1: 'tue', + 2: 'wed', + 3: 'thu', + 4: 'fri', + 5: 'sat', + 6: 'sun', }; return days.map((d) => map[d]).filter(Boolean); } -const ADD_TEAM_PATH = '/teams/new'; const EDIT_TEAM_PATH = (groupId: number) => `/teams/${groupId}/edit`; - type CreateTaskListResult = { id: number }; -function ListPage() { +export default function List() { const router = useRouter(); const qc = useQueryClient(); - const desktopSidebarRef = useRef(null); - - const [isPc, setIsPc] = useState(false); - const [isMobileUi, setIsMobileUi] = useState(false); - - useEffect(() => { - if (typeof window === 'undefined') return; - - const mqPc = window.matchMedia('(min-width: 1025px)'); - const mqMobileUi = window.matchMedia('(max-width: 1024px)'); - - const apply = () => { - setIsPc(mqPc.matches); - setIsMobileUi(mqMobileUi.matches); - }; - - apply(); - mqPc.addEventListener('change', apply); - mqMobileUi.addEventListener('change', apply); - - return () => { - mqPc.removeEventListener('change', apply); - mqMobileUi.removeEventListener('change', apply); - }; - }, []); - const [drawerOpen, setDrawerOpen] = useState(false); - const toggleDrawer = () => setDrawerOpen((p) => !p); - const closeDrawer = () => setDrawerOpen(false); + // [teamid] 라우트에서 teamId 받음 + const params = useParams<{ teamid?: string }>(); + const teamId = String(params?.teamid ?? '').trim(); - const { data: me } = useMe(); + const { data: me } = useMe(teamId); const groups = useMemo(() => { const list = @@ -452,18 +395,13 @@ function ListPage() { return list; }, [me?.memberships]); - const [activeGroupIdState, setActiveGroupIdState] = useState(undefined); - const urlGroupId = useInitialGroupId(); - - // URL 파라미터 → 유저 선택 → 첫 번째 그룹 순으로 활성 그룹 결정 - const activeGroupId = activeGroupIdState ?? urlGroupId ?? groups[0]?.id ?? 0; - + const activeGroupId = useMemo(() => groups[0]?.id ?? 0, [groups]); const activeGroup = useMemo( () => groups.find((g) => g.id === activeGroupId) ?? null, [groups, activeGroupId], ); - const groupDetailQuery = useGroupDetail(activeGroupId) as unknown as { + const groupDetailQuery = useGroupDetail(teamId, activeGroupId) as unknown as { data?: { taskLists?: Array<{ id: number; name: string; displayIndex: number }> }; isLoading?: boolean; isFetching?: boolean; @@ -502,6 +440,7 @@ function ListPage() { const selectedTodoKey = selectedTaskListId ? `taskList-${selectedTaskListId}` : 'taskList-0'; const { data: taskListByDate } = useTaskListByDate({ + teamId, groupId: activeGroupId, taskListId: selectedTaskListId, dateIso: selectedDateIso, @@ -514,15 +453,7 @@ function ListPage() { const sorted = [...taskLists].sort((a, b) => a.displayIndex - b.displayIndex); if (sorted.length === 0) { - return [ - { - id: 0, - key: 'taskList-0', - title: '제목 없음', - expanded: false, - items: [], - }, - ]; + return [{ id: 0, key: 'taskList-0', title: '제목 없음', expanded: false, items: [] }]; } return sorted.map((tl) => { @@ -592,9 +523,9 @@ function ListPage() { setAddTodoOpen(true); }; - const createTaskList = useCreateTaskList(); - const updateTaskList = useUpdateTaskList(); - const deleteTaskList = useDeleteTaskList(); + const createTaskList = useCreateTaskList(teamId); + const updateTaskList = useUpdateTaskList(teamId); + const deleteTaskList = useDeleteTaskList(teamId); const handleSubmitTodoModal = async () => { const name = todoNameDraft.trim(); @@ -632,13 +563,13 @@ function ListPage() { setCalendarModalOpen(true); }; - const createTask = useCreateTask(); - const patchTask = usePatchTask(); - const deleteTask = useDeleteTask(); + const createTask = useCreateTask(teamId); + const patchTask = usePatchTask(teamId); + const deleteTask = useDeleteTask(teamId); const invalidateCurrentList = async () => { await qc.invalidateQueries({ - queryKey: ['taskListByDate', activeGroupId, selectedTaskListId, selectedDateIso], + queryKey: ['taskListByDate', teamId, activeGroupId, selectedTaskListId, selectedDateIso], }); }; @@ -667,11 +598,16 @@ function ListPage() { start.setHours(hh, mm, 0, 0); const rawRepeatDays = (payload as unknown as { repeatDays?: unknown }).repeatDays; - const weekDays = freq === 'WEEKLY' ? toApiWeekDays(rawRepeatDays) : undefined; + + // ✅ WEEKLY: number[] + const weekDays = freq === 'WEEKLY' ? toApiWeekDayNums(rawRepeatDays) : undefined; + + // ✅ MONTHLY: monthDay 필요 const monthDay = freq === 'MONTHLY' ? start.getDate() : undefined; const startIso = toIsoWithOffset(start); + // ✅ CREATE는 /recurring 로 보냄(신규 API) if (!taskEditTarget) { await createTask.mutateAsync({ groupId: activeGroupId, @@ -690,20 +626,20 @@ function ListPage() { return; } - const body = { - name: title, - description: memo, - startDate: startIso, - frequencyType: freq, - weekDays, - monthDay, - }; - + // ✅ EDIT: recurringId 있으면 recurring 수정, 없으면 task PATCH await patchTask.mutateAsync({ groupId: activeGroupId, taskListId: selectedTaskListId, taskId: taskEditTarget.id, - body: body as unknown as never, + recurringId: taskEditTarget.recurringId ?? undefined, + body: { + name: title, + description: memo, + startDate: startIso, + frequencyType: freq, + weekDays, + monthDay, + }, }); await invalidateCurrentList(); @@ -714,7 +650,6 @@ function ListPage() { const [openedTaskMenuId, setOpenedTaskMenuId] = useState(null); const [openedTodoMenuKey, setOpenedTodoMenuKey] = useState(null); const [todoListDropdownOpen, setTodoListDropdownOpen] = useState(false); - const [teamMenuOpen, setTeamMenuOpen] = useState(false); useEffect(() => { @@ -768,38 +703,16 @@ function ListPage() { setSelectedTaskIdState(undefined); }; + // ✅ Detail Overlay const [detailMounted, setDetailMounted] = useState(false); const [detailOpen, setDetailOpen] = useState(false); - const setSidebarCollapsedByClick = (collapsed: boolean) => { - const root = desktopSidebarRef.current; - if (!root) return; - - const closeBtn = root.querySelector('button[aria-label="사이드바 닫기"]'); - const openBtn = root.querySelector('button[aria-label="사이드바 열기"]'); - - if (collapsed && closeBtn) closeBtn.click(); - if (!collapsed && openBtn) openBtn.click(); - }; - const openDetail = () => { - if (isMobileUi) { - setDetailMounted(true); - setDetailOpen(true); - return; - } - if (isPc) setSidebarCollapsedByClick(true); setDetailMounted(true); requestAnimationFrame(() => setDetailOpen(true)); }; const closeDetail = () => { - if (isMobileUi) { - setDetailOpen(false); - setDetailMounted(false); - return; - } - if (isPc) setSidebarCollapsedByClick(false); setDetailOpen(false); window.setTimeout(() => setDetailMounted(false), 260); }; @@ -818,8 +731,8 @@ function ListPage() { openDetail(); }; - const { data: detailComments = [] } = useTaskComments(selectedTaskId); - const createComment = useCreateTaskComment(); + const { data: detailComments = [] } = useTaskComments(teamId, selectedTaskId); + const createComment = useCreateTaskComment(teamId); const meWriter = useMemo(() => { return { @@ -829,78 +742,38 @@ function ListPage() { }; }, [me]); - const profile40 = useMemo(() => { - const url = me?.image || ''; - return ( -
- ); - }, [me?.image]); - - const profile32 = useMemo(() => { - const url = me?.image || ''; - return ( -
- ); - }, [me?.image]); - - const handleSelectGroup = (groupId: number) => { - setActiveGroupIdState(groupId); - setOpenedTaskMenuId(null); - setOpenedTodoMenuKey(null); - setTeamMenuOpen(false); - closeDrawer(); - }; - const handleToggleDone = async (taskId: number, nextDone: boolean) => { if (!activeGroupId || !selectedTaskListId) return; + // ✅ doneAt은 task PATCH로만 처리(반복/비반복 공통) await patchTask.mutateAsync({ groupId: activeGroupId, taskListId: selectedTaskListId, taskId, - body: { doneAt: nextDone ? new Date().toISOString() : null } as unknown as never, + recurringId: undefined, + body: { doneAt: nextDone ? new Date().toISOString() : null }, }); await invalidateCurrentList(); }; - const handleDeleteTask = async (taskId: number) => { + const handleDeleteTask = async (task: Task) => { if (!activeGroupId || !selectedTaskListId) return; await deleteTask.mutateAsync({ groupId: activeGroupId, taskListId: selectedTaskListId, - taskId, + taskId: task.id, + recurringId: task.recurringId ?? undefined, }); await invalidateCurrentList(); - - if (detailMounted && selectedTaskId === taskId) closeDetail(); + if (detailMounted && selectedTaskId === task.id) closeDetail(); }; const deleteGroup = async (groupId: number) => { console.warn('TODO: deleteGroup API not wired', groupId); - await qc.invalidateQueries({ queryKey: ['me'] }); + await qc.invalidateQueries({ queryKey: ['me', teamId] }); }; const handleClickTeamEdit = () => { @@ -917,47 +790,8 @@ function ListPage() { if (!ok) return; await deleteGroup(activeGroupId); - - const first = groups[0]?.id; - if (first) handleSelectGroup(first); }; - const drawerContent = ( - <> - } - label="팀 선택" - isSelected={false} - /> - - {groups.map((g) => ( - } - label={g.name} - isActive={g.id === activeGroupId} - onClick={() => handleSelectGroup(g.id)} - /> - ))} - - { - closeDrawer(); - router.push(ADD_TEAM_PATH); - }} - /> - -
- - } - label="자유게시판" - onClick={() => {}} - /> - - ); - const hasRealTaskList = isGroupDetailReady && taskLists.length > 0; const selectedIso = normalizeIsoForUi(getTaskIso(selectedTask), selectedDateKey); @@ -976,12 +810,7 @@ function ListPage() { const calendarInitialValues = useMemo(() => { if (!taskEditTarget) { - return { - startDate: selectedDate, - startTime: '09:00', - repeatType: 'none', - repeatDays: [], - }; + return { startDate: selectedDate, startTime: '09:00', repeatType: 'none', repeatDays: [] }; } const iso = normalizeIsoForUi(getTaskIso(taskEditTarget), selectedDateKey); @@ -1006,6 +835,14 @@ function ListPage() { }; }, [taskEditTarget, selectedDate, selectedDateKey]); + if (!teamId) { + return ( +
+
teamId 경로가 비어있습니다.
+
+ ); + } + return (
- {isPc ? ( -
- - !isCollapsed ? ( - } - label="팀 선택" - isSelected={false} - /> - ) : null - } - addButton={(isCollapsed) => ( - <> - {!isCollapsed ? ( - router.push(ADD_TEAM_PATH)} - /> - ) : null} - -
- - - } - label="자유게시판" - iconOnly={isCollapsed} - onClick={() => {}} - /> - - )} - > - {(isCollapsed) => - !isCollapsed ? ( - groups.map((g) => ( - } - label={g.name} - isActive={g.id === activeGroupId} - onClick={() => handleSelectGroup(g.id)} - /> - )) - ) : ( - } - label={activeGroup?.name ?? ''} - isActive - iconOnly - onClick={() => { - const first = groups[0]?.id; - if (first) handleSelectGroup(first); - }} - /> - ) - } -
-
- ) : null} - - {isMobileUi ? ( -
- {}} - /> -
- ) : null} - - {isMobileUi ? ( - - {drawerContent} - - ) : null} -
{formatYearMonth(viewDate)} + {/* ✅ 주 단위 이동 */}
setViewDate((d) => addMonths(d, -1))} + onClick={() => setViewDate((d) => addWeeks(d, -1))} /> setViewDate((d) => addMonths(d, 1))} + onClick={() => setViewDate((d) => addWeeks(d, 1))} />
@@ -1364,7 +1114,10 @@ function ListPage() { { - if (v) setSelectedDate(v); + if (v) { + setSelectedDate(v); + setViewDate(v); // ✅ 달력 선택 시 viewDate도 동기화 + } closeCalendar(); }} /> @@ -1378,7 +1131,10 @@ function ListPage() { { + setSelectedDate(next); + setViewDate(next); // ✅ 주바 선택 시 viewDate도 동기화 + }} />
@@ -1462,7 +1218,7 @@ function ListPage() { className={styles.taskMenuItem} onClick={async () => { setOpenedTaskMenuId(null); - await handleDeleteTask(task.id); + await handleDeleteTask(task); }} > 삭제하기 @@ -1488,12 +1244,18 @@ function ListPage() { {detailMounted && selectedTask ? (
-
e.stopPropagation()}> +
e.stopPropagation()} + > openTaskEdit(selectedTask)} onDelete={async () => { - await handleDeleteTask(selectedTask.id); + await handleDeleteTask(selectedTask); }} onClose={closeDetail} onCommentSubmit={async (content) => { @@ -1568,11 +1330,3 @@ function ListPage() {
); } - -export default function ListPageRoot() { - return ( - - - - ); -} diff --git a/src/app/(root)/list/hooks/queries.ts b/src/app/(root)/[teamid]/_domain/components/TaskList/queries.ts similarity index 62% rename from src/app/(root)/list/hooks/queries.ts rename to src/app/(root)/[teamid]/_domain/components/TaskList/queries.ts index 1edf564..3ea80f1 100644 --- a/src/app/(root)/list/hooks/queries.ts +++ b/src/app/(root)/[teamid]/_domain/components/TaskList/queries.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -/** ===== helpers (프로젝트 proxy route 정책에 맞춤) ===== */ +/** ===== helpers (proxy route) ===== */ function proxy(path: string) { const p = path.startsWith('/') ? path.slice(1) : path; return `/api/proxy/${p}`; @@ -32,11 +32,22 @@ async function safeReadJson(res: Response): Promise { } } -async function fetchJson(path: string, init?: RequestInit, message = '요청 실패'): Promise { +function withTeam(teamId: string, path: string) { + const t = String(teamId ?? '').trim(); + const p = path.startsWith('/') ? path.slice(1) : path; + return `${t}/${p}`; +} + +async function fetchJson( + teamId: string, + path: string, + init?: RequestInit, + message = '요청 실패', +): Promise { const method = (init?.method ?? 'GET').toUpperCase(); const isBodyless = method === 'GET' || method === 'HEAD' || method === 'DELETE'; - const res = await fetch(proxy(path), { + const res = await fetch(proxy(withTeam(teamId, path)), { ...init, method, body: isBodyless ? undefined : init?.body, @@ -52,10 +63,15 @@ async function fetchJson(path: string, init?: RequestInit, message = '요청 return await safeReadJson(res); } -async function fetchVoid(path: string, init?: RequestInit, message = '요청 실패'): Promise { +async function fetchVoid( + teamId: string, + path: string, + init?: RequestInit, + message = '요청 실패', +): Promise { const method = (init?.method ?? 'GET').toUpperCase(); - const res = await fetch(proxy(path), { + const res = await fetch(proxy(withTeam(teamId, path)), { ...init, method, body: method === 'DELETE' || method === 'GET' || method === 'HEAD' ? undefined : init?.body, @@ -67,14 +83,6 @@ async function fetchVoid(path: string, init?: RequestInit, message = '요청 실 /** ===== types (swagger 기반 최소 필요 필드) ===== */ export type ApiFrequency = 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY'; -export type ApiWeekDay = - | 'MONDAY' - | 'TUESDAY' - | 'WEDNESDAY' - | 'THURSDAY' - | 'FRIDAY' - | 'SATURDAY' - | 'SUNDAY'; export type Group = { id: number; @@ -128,29 +136,30 @@ export type Task = { name: string; description?: string | null; - /** 서버가 date/startDate/startAt/scheduledAt 중 무엇을 주든 UI에서 흡수 가능하게 optional */ - date?: string; // ISO - startDate?: string; // ISO + date?: string; + startDate?: string; startAt?: string; scheduledAt?: string; doneAt?: string | null; - /** 서버가 frequency/frequencyType/repeatType 중 무엇을 주든 UI에서 흡수 가능 */ frequency?: ApiFrequency; frequencyType?: ApiFrequency; repeatType?: ApiFrequency; - /** 주/월 반복 부가 */ - weekDays?: ApiWeekDay[]; - repeatWeekDays?: ApiWeekDay[]; - repeatDays?: unknown; // 모달 포맷 들어올 수도 있어 방어 + /** ✅ 서버는 number[] */ + weekDays?: number[]; + repeatWeekDays?: number[]; + repeatDays?: unknown; monthDay?: number; repeatMonthDay?: number; commentCount: number; writer?: TaskWriter; + + /** ✅ 반복 task면 존재 */ + recurringId?: number; }; export type TaskListByDateResponse = { @@ -184,22 +193,29 @@ export type Comment = { }; /** ===== queries ===== */ -export function useMe() { +export function useMe(teamId: string) { return useQuery({ - queryKey: ['me'], + queryKey: ['me', teamId], + enabled: !!teamId, queryFn: async () => { - return fetchJson('user', undefined, '유저 정보를 불러오는데 실패했습니다.'); + return fetchJson( + teamId, + 'user', + undefined, + '유저 정보를 불러오는데 실패했습니다.', + ); }, staleTime: 30_000, }); } -export function useGroupDetail(groupId: number) { +export function useGroupDetail(teamId: string, groupId: number) { return useQuery({ - queryKey: ['groupDetail', groupId], - enabled: groupId > 0, + queryKey: ['groupDetail', teamId, groupId], + enabled: !!teamId && groupId > 0, queryFn: async () => { return fetchJson( + teamId, `groups/${groupId}`, undefined, '그룹 정보를 불러오는데 실패했습니다.', @@ -210,17 +226,19 @@ export function useGroupDetail(groupId: number) { } export function useTaskListByDate(params: { + teamId: string; groupId: number; taskListId: number; dateIso: string; }) { - const { groupId, taskListId, dateIso } = params; + const { teamId, groupId, taskListId, dateIso } = params; return useQuery({ - queryKey: ['taskListByDate', groupId, taskListId, dateIso], - enabled: groupId > 0 && taskListId > 0 && !!dateIso, + queryKey: ['taskListByDate', teamId, groupId, taskListId, dateIso], + enabled: !!teamId && groupId > 0 && taskListId > 0 && !!dateIso, queryFn: async () => { return fetchJson( + teamId, `groups/${groupId}/task-lists/${taskListId}?date=${encodeURIComponent(dateIso)}`, { cache: 'no-store' }, '할 일 목록을 불러오는데 실패했습니다.', @@ -229,12 +247,13 @@ export function useTaskListByDate(params: { }); } -export function useTaskComments(taskId: number) { +export function useTaskComments(teamId: string, taskId: number) { return useQuery({ - queryKey: ['taskComments', taskId], - enabled: taskId > 0, + queryKey: ['taskComments', teamId, taskId], + enabled: !!teamId && taskId > 0, queryFn: async () => { return fetchJson( + teamId, `tasks/${taskId}/comments`, undefined, '댓글을 불러오는데 실패했습니다.', @@ -244,68 +263,74 @@ export function useTaskComments(taskId: number) { } /** ===== mutations ===== */ -export function useCreateTaskList() { +export function useCreateTaskList(teamId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (vars: { groupId: number; name: string }) => { return fetchJson( + teamId, `groups/${vars.groupId}/task-lists`, { method: 'POST', body: JSON.stringify({ name: vars.name }) }, '할 일 목록 생성에 실패했습니다.', ); }, onSuccess: async (_, vars) => { - await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] }); + await qc.invalidateQueries({ queryKey: ['groupDetail', teamId, vars.groupId] }); }, }); } -export function useUpdateTaskList() { +export function useUpdateTaskList(teamId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (vars: { groupId: number; taskListId: number; name: string }) => { return fetchJson( + teamId, `groups/${vars.groupId}/task-lists/${vars.taskListId}`, { method: 'PATCH', body: JSON.stringify({ name: vars.name }) }, '할 일 목록 수정에 실패했습니다.', ); }, onSuccess: async (_, vars) => { - await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] }); - // dateIso까지 포함된 키도 같이 invalidate되도록 exact:false + await qc.invalidateQueries({ queryKey: ['groupDetail', teamId, vars.groupId] }); await qc.invalidateQueries({ - queryKey: ['taskListByDate', vars.groupId, vars.taskListId], + queryKey: ['taskListByDate', teamId, vars.groupId, vars.taskListId], exact: false, }); }, }); } -export function useDeleteTaskList() { +export function useDeleteTaskList(teamId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (vars: { groupId: number; taskListId: number }) => { return fetchVoid( + teamId, `groups/${vars.groupId}/task-lists/${vars.taskListId}`, { method: 'DELETE' }, '할 일 목록 삭제에 실패했습니다.', ); }, onSuccess: async (_, vars) => { - await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] }); + await qc.invalidateQueries({ queryKey: ['groupDetail', teamId, vars.groupId] }); await qc.invalidateQueries({ - queryKey: ['taskListByDate', vars.groupId, vars.taskListId], + queryKey: ['taskListByDate', teamId, vars.groupId, vars.taskListId], exact: false, }); }, }); } -/** Task 생성 */ -export function useCreateTask() { +/** + * ✅ (반복) 생성은 신규 API: /recurring + * - WEEKLY: weekDays number[] + * - MONTHLY: monthDay 필수 + */ +export function useCreateTask(teamId: string) { const qc = useQueryClient(); return useMutation({ @@ -314,13 +339,15 @@ export function useCreateTask() { taskListId: number; name: string; description?: string; - startDate: string; // ISO + startDate: string; frequencyType: ApiFrequency; - weekDays?: ApiWeekDay[]; + weekDays?: number[]; monthDay?: number; }) => { - return fetchJson( - `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks`, + // ✅ 신규 recurring + return fetchJson( + teamId, + `groups/${vars.groupId}/task-lists/${vars.taskListId}/recurring`, { method: 'POST', body: JSON.stringify({ @@ -337,14 +364,19 @@ export function useCreateTask() { }, onSuccess: async (_, vars) => { await qc.invalidateQueries({ - queryKey: ['taskListByDate', vars.groupId, vars.taskListId], + queryKey: ['taskListByDate', teamId, vars.groupId, vars.taskListId], exact: false, }); }, }); } -export function usePatchTask() { +/** + * ✅ 수정: + * - recurringId 있으면: /recurring/{recurringId} + * - 없으면: /tasks/{taskId} (일반 task 수정/완료체크) + */ +export function usePatchTask(teamId: string) { const qc = useQueryClient(); return useMutation({ @@ -352,17 +384,42 @@ export function usePatchTask() { groupId: number; taskListId: number; taskId: number; + recurringId?: number; body: { name?: string; description?: string; - startDate?: string; // + startDate?: string; frequencyType?: ApiFrequency; - weekDays?: ApiWeekDay[]; + weekDays?: number[]; monthDay?: number; doneAt?: string | null; }; }) => { + const isDonePatchOnly = Object.keys(vars.body).length === 1 && 'doneAt' in vars.body; + + // ✅ doneAt은 무조건 tasks PATCH + if (isDonePatchOnly) { + return fetchJson( + teamId, + `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks/${vars.taskId}`, + { method: 'PATCH', body: JSON.stringify(vars.body) }, + '할 일 수정에 실패했습니다.', + ); + } + + // ✅ recurring 수정 + if (vars.recurringId) { + return fetchJson( + teamId, + `groups/${vars.groupId}/task-lists/${vars.taskListId}/recurring/${vars.recurringId}`, + { method: 'PATCH', body: JSON.stringify(vars.body) }, + '할 일 수정에 실패했습니다.', + ); + } + + // ✅ 일반 task 수정 return fetchJson( + teamId, `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks/${vars.taskId}`, { method: 'PATCH', body: JSON.stringify(vars.body) }, '할 일 수정에 실패했습니다.', @@ -370,20 +427,40 @@ export function usePatchTask() { }, onSuccess: async (_, vars) => { await qc.invalidateQueries({ - queryKey: ['taskListByDate', vars.groupId, vars.taskListId], + queryKey: ['taskListByDate', teamId, vars.groupId, vars.taskListId], exact: false, }); - await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] }); + await qc.invalidateQueries({ queryKey: ['taskComments', teamId, vars.taskId] }); }, }); } -export function useDeleteTask() { +/** + * ✅ 삭제: + * - recurringId 있으면: /tasks/{taskId}/recurring/{recurringId} + * - 없으면: /tasks/{taskId} + */ +export function useDeleteTask(teamId: string) { const qc = useQueryClient(); return useMutation({ - mutationFn: async (vars: { groupId: number; taskListId: number; taskId: number }) => { + mutationFn: async (vars: { + groupId: number; + taskListId: number; + taskId: number; + recurringId?: number; + }) => { + if (vars.recurringId) { + return fetchVoid( + teamId, + `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks/${vars.taskId}/recurring/${vars.recurringId}`, + { method: 'DELETE' }, + '할 일 삭제에 실패했습니다.', + ); + } + return fetchVoid( + teamId, `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks/${vars.taskId}`, { method: 'DELETE' }, '할 일 삭제에 실패했습니다.', @@ -391,27 +468,28 @@ export function useDeleteTask() { }, onSuccess: async (_, vars) => { await qc.invalidateQueries({ - queryKey: ['taskListByDate', vars.groupId, vars.taskListId], + queryKey: ['taskListByDate', teamId, vars.groupId, vars.taskListId], exact: false, }); - await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] }); + await qc.invalidateQueries({ queryKey: ['taskComments', teamId, vars.taskId] }); }, }); } -export function useCreateTaskComment() { +export function useCreateTaskComment(teamId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (vars: { taskId: number; content: string }) => { return fetchJson( + teamId, `tasks/${vars.taskId}/comments`, { method: 'POST', body: JSON.stringify({ content: vars.content }) }, '댓글 작성에 실패했습니다.', ); }, onSuccess: async (_, vars) => { - await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] }); + await qc.invalidateQueries({ queryKey: ['taskComments', teamId, vars.taskId] }); }, }); } diff --git a/src/app/(root)/[teamid]/tasklist/page.tsx b/src/app/(root)/[teamid]/tasklist/page.tsx new file mode 100644 index 0000000..49835e6 --- /dev/null +++ b/src/app/(root)/[teamid]/tasklist/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { useParams } from 'next/navigation'; + +const TaskList = dynamic( + () => import('@/app/(root)/[teamid]/_domain/components/TaskList/TaskList'), + { + ssr: false, + }, +); + +export default function TaskListPage() { + const params = useParams<{ teamid: string }>(); + + return ; +}