diff --git a/src/app/(root)/list/hooks/queries.ts b/src/app/(root)/list/hooks/queries.ts index 5b58a11..1edf564 100644 --- a/src/app/(root)/list/hooks/queries.ts +++ b/src/app/(root)/list/hooks/queries.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -/** ===== helpers ===== */ +/** ===== helpers (프로젝트 proxy route 정책에 맞춤) ===== */ function proxy(path: string) { const p = path.startsWith('/') ? path.slice(1) : path; return `/api/proxy/${p}`; @@ -15,25 +15,66 @@ async function assertOk(res: Response, message: string) { } } +/** + * proxy 라우트가 수정되어 "null/빈문자열"도 반환 가능하므로 + * json 파싱은 "텍스트가 있을 때만" 수행해야 안전함. + */ +async function safeReadJson(res: Response): Promise { + if (res.status === 204) return undefined as unknown as T; + + const text = await res.text().catch(() => ''); + if (!text) return undefined as unknown as T; + + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`응답 JSON 파싱 실패: ${text.slice(0, 200)}`); + } +} + async function fetchJson(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), { ...init, + method, + body: isBodyless ? undefined : init?.body, + credentials: 'include', headers: { ...(init?.headers ?? {}), - ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...(isBodyless ? {} : { 'Content-Type': 'application/json' }), + Accept: 'application/json', }, }); + await assertOk(res, message); - return (await res.json()) as T; + return await safeReadJson(res); } async function fetchVoid(path: string, init?: RequestInit, message = '요청 실패'): Promise { - const res = await fetch(proxy(path), init); + const method = (init?.method ?? 'GET').toUpperCase(); + + const res = await fetch(proxy(path), { + ...init, + method, + body: method === 'DELETE' || method === 'GET' || method === 'HEAD' ? undefined : init?.body, + credentials: 'include', + }); + await assertOk(res, 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; @@ -80,15 +121,36 @@ export type TaskList = { updatedAt: string; }; +export type TaskWriter = { id: number; nickname: string; image: string | null } | null; + export type Task = { id: number; name: string; - description: string | null; - date: string; // ISO - doneAt: string | null; - frequency: ApiFrequency; + description?: string | null; + + /** 서버가 date/startDate/startAt/scheduledAt 중 무엇을 주든 UI에서 흡수 가능하게 optional */ + date?: string; // ISO + startDate?: string; // ISO + startAt?: string; + scheduledAt?: string; + + doneAt?: string | null; + + /** 서버가 frequency/frequencyType/repeatType 중 무엇을 주든 UI에서 흡수 가능 */ + frequency?: ApiFrequency; + frequencyType?: ApiFrequency; + repeatType?: ApiFrequency; + + /** 주/월 반복 부가 */ + weekDays?: ApiWeekDay[]; + repeatWeekDays?: ApiWeekDay[]; + repeatDays?: unknown; // 모달 포맷 들어올 수도 있어 방어 + + monthDay?: number; + repeatMonthDay?: number; + commentCount: number; - writer?: { id: number; nickname: string; image: string | null } | null; + writer?: TaskWriter; }; export type TaskListByDateResponse = { @@ -125,8 +187,9 @@ export type Comment = { export function useMe() { return useQuery({ queryKey: ['me'], - queryFn: () => - fetchJson('user', undefined, '유저 정보를 불러오는데 실패했습니다.'), + queryFn: async () => { + return fetchJson('user', undefined, '유저 정보를 불러오는데 실패했습니다.'); + }, staleTime: 30_000, }); } @@ -135,12 +198,13 @@ export function useGroupDetail(groupId: number) { return useQuery({ queryKey: ['groupDetail', groupId], enabled: groupId > 0, - queryFn: () => - fetchJson( + queryFn: async () => { + return fetchJson( `groups/${groupId}`, undefined, '그룹 정보를 불러오는데 실패했습니다.', - ), + ); + }, staleTime: 10_000, }); } @@ -155,12 +219,13 @@ export function useTaskListByDate(params: { return useQuery({ queryKey: ['taskListByDate', groupId, taskListId, dateIso], enabled: groupId > 0 && taskListId > 0 && !!dateIso, - queryFn: () => - fetchJson( + queryFn: async () => { + return fetchJson( `groups/${groupId}/task-lists/${taskListId}?date=${encodeURIComponent(dateIso)}`, { cache: 'no-store' }, '할 일 목록을 불러오는데 실패했습니다.', - ), + ); + }, }); } @@ -168,12 +233,13 @@ export function useTaskComments(taskId: number) { return useQuery({ queryKey: ['taskComments', taskId], enabled: taskId > 0, - queryFn: () => - fetchJson( + queryFn: async () => { + return fetchJson( `tasks/${taskId}/comments`, undefined, '댓글을 불러오는데 실패했습니다.', - ), + ); + }, }); } @@ -182,12 +248,13 @@ export function useCreateTaskList() { const qc = useQueryClient(); return useMutation({ - mutationFn: (vars: { groupId: number; name: string }) => - fetchJson( + mutationFn: async (vars: { groupId: number; name: string }) => { + return fetchJson( `groups/${vars.groupId}/task-lists`, { method: 'POST', body: JSON.stringify({ name: vars.name }) }, '할 일 목록 생성에 실패했습니다.', - ), + ); + }, onSuccess: async (_, vars) => { await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] }); }, @@ -198,15 +265,20 @@ export function useUpdateTaskList() { const qc = useQueryClient(); return useMutation({ - mutationFn: (vars: { groupId: number; taskListId: number; name: string }) => - fetchJson( + mutationFn: async (vars: { groupId: number; taskListId: number; name: string }) => { + return fetchJson( `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] }); - await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); + // dateIso까지 포함된 키도 같이 invalidate되도록 exact:false + await qc.invalidateQueries({ + queryKey: ['taskListByDate', vars.groupId, vars.taskListId], + exact: false, + }); }, }); } @@ -215,39 +287,39 @@ export function useDeleteTaskList() { const qc = useQueryClient(); return useMutation({ - mutationFn: (vars: { groupId: number; taskListId: number }) => - fetchVoid( + mutationFn: async (vars: { groupId: number; taskListId: number }) => { + return fetchVoid( `groups/${vars.groupId}/task-lists/${vars.taskListId}`, { method: 'DELETE' }, '할 일 목록 삭제에 실패했습니다.', - ), + ); + }, onSuccess: async (_, vars) => { await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] }); - await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); + await qc.invalidateQueries({ + queryKey: ['taskListByDate', vars.groupId, vars.taskListId], + exact: false, + }); }, }); } -/** - * TaskRecurringCreateDto 기반 - * - ONCE도 여기로 POST /tasks - * - weekly/monthly면 weekDays/monthDay 전달 가능 - */ +/** Task 생성 */ export function useCreateTask() { const qc = useQueryClient(); return useMutation({ - mutationFn: (vars: { + mutationFn: async (vars: { groupId: number; taskListId: number; name: string; description?: string; startDate: string; // ISO frequencyType: ApiFrequency; - weekDays?: string[]; // ['MONDAY'...] + weekDays?: ApiWeekDay[]; monthDay?: number; - }) => - fetchJson( + }) => { + return fetchJson( `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks`, { method: 'POST', @@ -261,9 +333,13 @@ export function useCreateTask() { }), }, '할 일 생성에 실패했습니다.', - ), + ); + }, onSuccess: async (_, vars) => { - await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); + await qc.invalidateQueries({ + queryKey: ['taskListByDate', vars.groupId, vars.taskListId], + exact: false, + }); }, }); } @@ -272,19 +348,31 @@ export function usePatchTask() { const qc = useQueryClient(); return useMutation({ - mutationFn: (vars: { + mutationFn: async (vars: { groupId: number; taskListId: number; taskId: number; - body: { name?: string; description?: string; date?: string; doneAt?: string | null }; - }) => - fetchJson( + body: { + name?: string; + description?: string; + startDate?: string; // + frequencyType?: ApiFrequency; + weekDays?: ApiWeekDay[]; + monthDay?: number; + doneAt?: string | null; + }; + }) => { + return fetchJson( `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks/${vars.taskId}`, { method: 'PATCH', body: JSON.stringify(vars.body) }, '할 일 수정에 실패했습니다.', - ), + ); + }, onSuccess: async (_, vars) => { - await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); + await qc.invalidateQueries({ + queryKey: ['taskListByDate', vars.groupId, vars.taskListId], + exact: false, + }); await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] }); }, }); @@ -294,14 +382,18 @@ export function useDeleteTask() { const qc = useQueryClient(); return useMutation({ - mutationFn: (vars: { groupId: number; taskListId: number; taskId: number }) => - fetchVoid( + mutationFn: async (vars: { groupId: number; taskListId: number; taskId: number }) => { + return fetchVoid( `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks/${vars.taskId}`, { method: 'DELETE' }, '할 일 삭제에 실패했습니다.', - ), + ); + }, onSuccess: async (_, vars) => { - await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); + await qc.invalidateQueries({ + queryKey: ['taskListByDate', vars.groupId, vars.taskListId], + exact: false, + }); await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] }); }, }); @@ -311,15 +403,15 @@ export function useCreateTaskComment() { const qc = useQueryClient(); return useMutation({ - mutationFn: (vars: { taskId: number; content: string }) => - fetchJson( + mutationFn: async (vars: { taskId: number; content: string }) => { + return fetchJson( `tasks/${vars.taskId}/comments`, { method: 'POST', body: JSON.stringify({ content: vars.content }) }, '댓글 작성에 실패했습니다.', - ), + ); + }, onSuccess: async (_, vars) => { await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] }); - // commentCount가 바뀌니까 리스트도 갱신 필요 (상위에서 invalidate 추가로 해도 됨) }, }); } diff --git a/src/app/(root)/list/list.module.css b/src/app/(root)/list/list.module.css index e562f21..8d3e0da 100644 --- a/src/app/(root)/list/list.module.css +++ b/src/app/(root)/list/list.module.css @@ -73,7 +73,7 @@ display: none; } -/* ✅ 1024px 이상에서는 데스크탑 Sidebar가 보이도록 강제 (Sidebar 컴포넌트의 1199px 이하 숨김을 override) */ +/* ✅ 1024px 이상에서는 데스크탑 Sidebar가 보이도록 강제 */ @media (min-width: 1024px) and (max-width: 1199px) { .desktopSidebar { display: flex !important; @@ -320,7 +320,7 @@ color: #64748b; } -/* ✅ 모바일/태블릿: 투두카드 폭 고정(모바일 180 / 태블릿 240) + 버튼 같은 줄 */ +/* ✅ 모바일/태블릿: 투두카드 폭 고정 */ @media (max-width: 1024px) { .mobileTodoRow { display: flex !important; @@ -329,7 +329,6 @@ min-width: 0 !important; } - /* ✅ 한 줄 컨테이너 */ .mobileTodoInlineCard { display: flex !important; align-items: center !important; @@ -338,15 +337,13 @@ min-width: 0 !important; } - /* ✅ 투두카드 영역: 모바일 기본 180 고정 */ .todoCardWrap { width: 180px !important; - flex: 0 0 180px !important; /* grow=0, shrink=0 */ + flex: 0 0 180px !important; min-width: 180px !important; max-width: 180px !important; } - /* ✅ 버튼은 오른쪽 고정 */ .mobileAddBtnWrap { margin-left: auto !important; width: 112px !important; @@ -354,7 +351,6 @@ } } -/* ✅ 태블릿만: 투두카드 폭 240 고정 */ @media (min-width: 744px) and (max-width: 1024px) { .todoCardWrap { width: 240px !important; @@ -364,7 +360,6 @@ } } -/* ✅ TodoCard 자체는 부모폭 100% */ @media (max-width: 1024px) { .todoCardShell, .todoCardShellInner { @@ -416,7 +411,7 @@ .stage { width: 100%; max-width: 1024px; - margin-top: 32px; /* ✅ 요청 */ + margin-top: 32px; } .body { @@ -424,7 +419,7 @@ gap: 0; justify-content: flex-start; align-items: stretch; - margin-top: 15px; /* ✅ 요청 */ + margin-top: 15px; } .leftCol { @@ -455,7 +450,7 @@ max-width: 1024px; flex: 0 0 auto; background: #ffffff; - border-radius: 0; /* ✅ 요청: 테블릿 radius 제거 */ + border-radius: 0; overflow: visible; min-width: 0; } @@ -483,15 +478,6 @@ } } -@media (min-width: 744px) and (max-width: 1024px) { - .mobileTodoInlineCard { - width: clamp(220px, 35vw, 270px); - flex: 0 0 clamp(220px, 35vw, 270px); - min-width: 220px; - max-width: 270px; - } -} - .todoArrowBtn { position: absolute; right: 10px; @@ -668,36 +654,30 @@ padding: 28px; } -/* ✅ TaskDetailCard header 레이아웃을 “X 단독 / 제목+케밥 한줄”로 강제 */ +/* ✅ TaskDetailCard header 레이아웃 */ .detailOverlay :global([class*='TaskDetailCard-module__'][class*='header']) { display: grid !important; grid-template-columns: 1fr auto !important; grid-template-rows: auto auto !important; - grid-template-areas: - 'close close' - 'title kebab' !important; + grid-template-areas: 'close close' 'title kebab' !important; align-items: center !important; } -/* X 버튼 */ .detailOverlay :global([class*='TaskDetailCard-module__'][class*='closeButton']) { grid-area: close !important; justify-self: start !important; align-self: start !important; } -/* 제목 */ .detailOverlay :global([class*='TaskDetailCard-module__'][class*='title']) { grid-area: title !important; } -/* 케밥 */ .detailOverlay :global([class*='TaskDetailCard-module__'][class*='kebabWrapper']) { grid-area: kebab !important; justify-self: end !important; } -/* 간격: PC/태블릿 74px, 모바일 20px */ @media (min-width: 744px) { .detailOverlay :global([class*='TaskDetailCard-module__'][class*='title']), .detailOverlay :global([class*='TaskDetailCard-module__'][class*='kebabWrapper']) { @@ -712,6 +692,12 @@ } } +/* ✅ 완료 시 TaskDetailCard title 줄+톤다운 (TaskDetailCard 안 건드림) */ +.detailInner[data-done='true'] :global([class*='TaskDetailCard-module__'][class*='title']) { + text-decoration: line-through !important; + opacity: 0.45 !important; +} + /* ✅ 모바일/태블릿: 애니메이션 제거 + 전체 화면 */ @media (max-width: 1024px) { .detailOverlay { @@ -740,10 +726,6 @@ } } -:global(.TaskDetailCard-module__8btaCa__title) { - margin: 0 !important; -} - @media (max-width: 1024px) { .taskMenu { top: 52px !important; @@ -752,86 +734,137 @@ } } -/* ===== Sidebar Team Select wrapper (PC/Drawer) ===== */ -.sidebarTeamSelectWrap { - position: relative; - width: 100%; +/* ✅ dropdown option disabled 스타일 */ +.todoListOption:disabled { + opacity: 0.5; + cursor: not-allowed; } -.drawerTeamSelectWrap { - position: relative; +/* =========================== + Mobile/Tablet Sidebar label overflow fix + =========================== */ + +.drawerFixWrap { width: 100%; - padding-bottom: 8px; + min-width: 0; } -/* ✅ 모바일/태블릿: 투두카드 + "할일 추가" 한 줄로 (구조 변경 없이 CSS만) */ -@media (max-width: 1024px) { - /* 한 줄 컨테이너 */ - .mobileTodoRow { - display: flex !important; - align-items: center !important; - gap: 12px !important; - width: 100% !important; - min-width: 0 !important; - } + +.drawerFixWrap :global(button), +.drawerFixWrap :global(a) { + min-width: 0; } -/* ✅ 모바일/태블릿: (1번처럼) 투두카드 + "할일 추가" 같은 줄 */ -@media (max-width: 1024px) { - /* row는 그냥 100% */ - .mobileTodoRow { - width: 100% !important; - min-width: 0 !important; - } +.drawerFixWrap :global(span), +.drawerFixWrap :global(p), +.drawerFixWrap :global(div) { + min-width: 0; +} - /* ✅ 여기서 핵심: 버튼이 같은 줄에 오도록 flex 컨테이너로 만든다 */ - .mobileTodoInlineCard { - display: flex !important; - align-items: center !important; - gap: 12px !important; +.drawerFixWrap :global([class*='label']), +.drawerFixWrap :global([class*='text']), +.drawerFixWrap :global([class*='name']) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - width: 100% !important; - flex: 1 1 auto !important; +.drawerFixWrap :global(button:hover) *, +.drawerFixWrap :global(button[aria-current='page']) *, +.drawerFixWrap :global(button[aria-selected='true']) * { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - /* 기존 clamp 고정폭 제거 */ - min-width: 0 !important; - max-width: none !important; - } +/* ===== TeamHeader (settingBig 클릭 메뉴) ===== */ +.teamMenu { + position: absolute; + right: 12px; + top: 56px; + z-index: 60; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + overflow: hidden; + min-width: 120px; +} - /* 투두카드 영역은 남는 공간 전부 먹게 */ - .todoCardWrap { - flex: 1 1 auto !important; - min-width: 0 !important; +.teamMenuItem { + width: 100%; + padding: 10px 12px; + text-align: left; + background: transparent; + border: 0; + text-align: center; + cursor: pointer; +} + +.teamMenuItem:hover { + background: rgba(15, 23, 42, 0.04); +} + +/* ===== 모바일/태블릿: 팀선택(hover) 시 내용이 박스 밖으로 튀는 문제 방지 ===== */ +@media (max-width: 1024px) { + :global([class*='SidebarTeamSelect-module__'] *), + :global([class*='SidebarButton-module__'] *), + :global([class*='SidebarAddButton-module__'] *) { + max-width: 100%; } - .todoCardShell, - .todoCardShellInner { - width: 100% !important; + :global([class*='SidebarTeamSelect-module__'] button), + :global([class*='SidebarTeamSelect-module__'] a), + :global([class*='SidebarButton-module__'] button), + :global([class*='SidebarButton-module__'] a) { + overflow: hidden; } - /* 버튼은 고정폭 유지하면서 오른쪽 */ - .mobileAddBtnWrap { - margin-left: 0 !important; - flex: 0 0 112px !important; - width: 112px !important; + :global([class*='SidebarTeamSelect-module__'] span), + :global([class*='SidebarButton-module__'] span) { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } -@media (max-width: 1024px) { - /* ✅ 여기 폭 고정(clamp) 전부 무력화: row 전체 폭을 먹게 */ - .mobileTodoInlineCard { - width: 100% !important; - max-width: none !important; - flex: 1 1 auto !important; - min-width: 0 !important; - display: flex !important; - align-items: center !important; - gap: 12px !important; - } +/* ✅ calendar popover */ +.calendarPopover { + position: absolute; + top: 44px; + right: 0; + z-index: 80; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.14); + padding: 10px; +} - /* ✅ 버튼을 진짜 맨 오른쪽으로 */ - .mobileAddBtnWrap { - margin-left: auto !important; - flex: 0 0 112px !important; - width: 112px !important; +/* ✅ 모바일/태블릿: 케밥 "버튼은 유지", 아이콘만 숨김 (드롭다운은 클릭으로 뜸) */ +@media (max-width: 1024px) { + /* TodoCard / TaskListItem 케밥 버튼 내부의 svg/img만 투명 */ + :global([class*='TodoCard-module__'] svg), + :global([class*='TodoCard-module__'] img), + :global([class*='TaskListItem-module__'] svg), + :global([class*='TaskListItem-module__'] img) { + /* 전체를 죽이면 다른 아이콘도 죽을 수 있으니 아래 범위를 더 좁힘 */ + } + + :global([class*='TodoCard-module__'] [aria-label*='더보기'] svg), + :global([class*='TodoCard-module__'] [aria-label*='더보기'] img), + :global([class*='TodoCard-module__'] [aria-label*='kebab'] svg), + :global([class*='TodoCard-module__'] [aria-label*='kebab'] img), + :global([class*='TodoCard-module__'] [aria-label*='Kebab'] svg), + :global([class*='TodoCard-module__'] [aria-label*='Kebab'] img), + :global([class*='TaskListItem-module__'] [aria-label*='더보기'] svg), + :global([class*='TaskListItem-module__'] [aria-label*='더보기'] img), + :global([class*='TaskListItem-module__'] [aria-label*='kebab'] svg), + :global([class*='TaskListItem-module__'] [aria-label*='kebab'] img), + :global([class*='TaskListItem-module__'] [aria-label*='Kebab'] svg), + :global([class*='TaskListItem-module__'] [aria-label*='Kebab'] img) { + opacity: 0 !important; } } diff --git a/src/app/(root)/list/page.tsx b/src/app/(root)/list/page.tsx index 89bbbbf..9733eaf 100644 --- a/src/app/(root)/list/page.tsx +++ b/src/app/(root)/list/page.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useEffect, useMemo, useRef, useState, type MouseEvent } 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 styles from './list.module.css'; @@ -13,6 +14,7 @@ import MobileDrawer from '@/components/sidebar/MobileDrawer'; import SidebarButton from '@/components/sidebar/SidebarButton'; import SidebarAddButton from '@/components/sidebar/SidebarAddButton'; +import SidebarTeamSelect from '@/components/sidebar/SidebarTeamSelect'; import WeekDateBar from '@/components/calendar/CalendarButton/WeekDateBar'; @@ -37,6 +39,8 @@ import type { CalenderModalSubmitPayload } from '@/components/Modal/domain/compo import AddTodoList from '@/components/Modal/domain/components/AddTodoList/AddTodoList'; +import Calendar from '@/components/calendar/Calendar'; + import { type ApiFrequency, type Task, @@ -53,7 +57,6 @@ import { useUpdateTaskList, } from '@/app/(root)/list/hooks/queries'; -/** ✅ 체크박스/케밥/버튼 클릭이면 디테일 오픈 금지 */ function isOpenDetailBlockedTarget(target: HTMLElement | null) { if (!target) return false; @@ -86,34 +89,121 @@ function isOpenDetailBlockedTarget(target: HTMLElement | null) { return false; } +function isKebabTrigger(target: HTMLElement | null) { + if (!target) return false; + + const labeled = target.closest('[aria-label]') as HTMLElement | null; + if (labeled) { + const v = (labeled.getAttribute('aria-label') ?? '').toLowerCase(); + if (v.includes('kebab') || v.includes('더보기') || v.includes('케밥')) return true; + } + + const cls = (target.className ?? '').toString().toLowerCase(); + if (cls.includes('kebab') || cls.includes('more')) return true; + + if (target.tagName.toLowerCase() === 'svg' || target.tagName.toLowerCase() === 'path') { + const p = target.parentElement; + if (p && isKebabTrigger(p)) return true; + } + + return false; +} + function formatYearMonth(date: Date) { return `${date.getFullYear()}년 ${date.getMonth() + 1}월`; } - function addMonths(base: Date, diff: number) { const d = new Date(base); d.setMonth(d.getMonth() + diff); return d; } - function toDateKey(d: Date) { const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; } - -function toIsoAtStartOfDay(date: Date) { +function pad2(n: number) { + return String(n).padStart(2, '0'); +} +function tzOffsetString(date: Date) { + const offsetMin = -date.getTimezoneOffset(); + const sign = offsetMin >= 0 ? '+' : '-'; + const abs = Math.abs(offsetMin); + const hh = Math.floor(abs / 60); + const mm = abs % 60; + return `${sign}${pad2(hh)}:${pad2(mm)}`; +} +function toIsoWithOffset(date: Date) { + const y = date.getFullYear(); + const m = pad2(date.getMonth() + 1); + const d = pad2(date.getDate()); + const hh = pad2(date.getHours()); + const mm = pad2(date.getMinutes()); + const ss = pad2(date.getSeconds()); + return `${y}-${m}-${d}T${hh}:${mm}:${ss}${tzOffsetString(date)}`; +} +function toLocalNoonIso(date: Date) { const d = new Date(date); - d.setHours(0, 0, 0, 0); - return d.toISOString(); + d.setHours(12, 0, 0, 0); + return toIsoWithOffset(d); } -function frequencyLabel(freq?: ApiFrequency) { - if (freq === 'DAILY') return '매일반복'; - if (freq === 'WEEKLY') return '매주반복'; - if (freq === 'MONTHLY') return '매월반복'; - return undefined; +function formatKoreanDateOnly(isoOrKey?: string) { + const raw = (isoOrKey ?? '').trim(); + if (!raw) return ''; + + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + const [y, m, d] = raw.split('-').map((v) => Number(v)); + if (Number.isFinite(y) && Number.isFinite(m) && Number.isFinite(d)) + return `${y}년 ${m}월 ${d}일`; + return raw; + } + + const dt = new Date(raw); + if (Number.isNaN(dt.getTime())) return raw; + + return `${dt.getFullYear()}년 ${dt.getMonth() + 1}월 ${dt.getDate()}일`; +} + +function formatHHmmFromIso(iso?: string) { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +} + +function parseTimeToHM(input?: string): { hh: number; mm: number } { + const raw = (input ?? '').trim(); + if (!raw) return { hh: 9, mm: 0 }; + + const ko = raw.match(/(오전|오후)\s*(\d{1,2})\s*:\s*(\d{2})/); + if (ko) { + const ap = ko[1]; + let h = Number(ko[2]); + const m = Number(ko[3]); + if (!Number.isFinite(h) || !Number.isFinite(m)) return { hh: 9, mm: 0 }; + if (ap === '오후' && h < 12) h += 12; + if (ap === '오전' && h === 12) h = 0; + return { hh: h, mm: m }; + } + + const plain = raw.match(/(\d{1,2})\s*:\s*(\d{2})/); + if (plain) { + const h = Number(plain[1]); + const m = Number(plain[2]); + if (Number.isFinite(h) && Number.isFinite(m)) return { hh: h, mm: m }; + } + + return { hh: 9, mm: 0 }; +} + +type UiFrequency = 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY'; +function toUiFrequency(freq?: ApiFrequency): UiFrequency { + if (freq === 'DAILY') return 'DAILY'; + if (freq === 'WEEKLY') return 'WEEKLY'; + if (freq === 'MONTHLY') return 'MONTHLY'; + return 'ONCE'; } function safeDateFromPayload(v: unknown, fallback: Date) { @@ -126,14 +216,193 @@ function safeDateFromPayload(v: unknown, fallback: Date) { } type TodoCardData = { - id: number; // taskListId - key: string; // `taskList-${id}` + id: number; + key: string; title: string; expanded: boolean; items: TodoItem[]; }; +type ApiWeekDay = + | 'MONDAY' + | 'TUESDAY' + | 'WEDNESDAY' + | 'THURSDAY' + | 'FRIDAY' + | 'SATURDAY' + | 'SUNDAY'; + +function toApiWeekDays(input: unknown): ApiWeekDay[] | 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 out: ApiWeekDay[] = []; + 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); + } + return out.length > 0 ? out : undefined; +} + +function getTaskIso(task: Task | null | undefined) { + const t = task as unknown as + | { date?: string; startDate?: string; startAt?: string; scheduledAt?: string } + | null + | undefined; + return t?.startDate ?? t?.date ?? t?.startAt ?? t?.scheduledAt ?? ''; +} + +function normalizeFreq(v: unknown): ApiFrequency { + const raw = typeof v === 'string' ? v.trim().toUpperCase() : ''; + if (raw === 'DAILY') return 'DAILY'; + if (raw === 'WEEKLY') return 'WEEKLY'; + if (raw === 'MONTHLY') return 'MONTHLY'; + return 'ONCE'; +} + +function getTaskFrequency(task: Task | null | undefined): ApiFrequency { + const t = task as unknown as + | { + frequency?: unknown; + frequencyType?: unknown; + repeatType?: unknown; + } + | null + | undefined; + + return normalizeFreq(t?.frequencyType ?? t?.frequency ?? t?.repeatType); +} + +function getTaskWeekDays(task: Task | null | undefined): ApiWeekDay[] | undefined { + const t = task as unknown as + | { + weekDays?: ApiWeekDay[]; + repeatDays?: unknown; + repeatWeekDays?: ApiWeekDay[]; + } + | 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; +} + +function getTaskMonthDay(task: Task | null | undefined): number | undefined { + const t = task as unknown as { monthDay?: number; repeatMonthDay?: number } | null | undefined; + return t?.monthDay ?? t?.repeatMonthDay; +} + +function normalizeIsoForUi(isoLike: string, fallbackDateKey: string) { + const raw = (isoLike ?? '').trim(); + if (!raw) return toIsoWithOffset(new Date(`${fallbackDateKey}T09:00:00`)); + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return toIsoWithOffset(new Date(`${raw}T09:00:00`)); + 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 '토'; + return '일'; +} + +function frequencyLabelFromTask(task: Task | null | undefined, fallbackDateKey: string) { + const freq = getTaskFrequency(task); + + if (freq === 'DAILY') return '매일 반복'; + + if (freq === 'WEEKLY') { + const days = getTaskWeekDays(task); + if (days && days.length > 0) return `매주 반복(${days.map(weekDayKor).join(',')})`; + return '매주 반복'; + } + + if (freq === 'MONTHLY') { + const md = getTaskMonthDay(task); + if (typeof md === 'number' && Number.isFinite(md)) return `매월 반복(${md}일)`; + + const iso = normalizeIsoForUi(getTaskIso(task), fallbackDateKey); + const d = new Date(iso); + if (!Number.isNaN(d.getTime())) return `매월 반복(${d.getDate()}일)`; + return '매월 반복'; + } + + return undefined; +} + +function toModalRepeatDays(days?: ApiWeekDay[]) { + if (!days || days.length === 0) return []; + const map: Record = { + MONDAY: 'mon', + TUESDAY: 'tue', + WEDNESDAY: 'wed', + THURSDAY: 'thu', + FRIDAY: 'fri', + SATURDAY: 'sat', + SUNDAY: '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 }; + export default function ListPage() { + const router = useRouter(); const qc = useQueryClient(); const desktopSidebarRef = useRef(null); @@ -165,7 +434,6 @@ export default function ListPage() { const toggleDrawer = () => setDrawerOpen((p) => !p); const closeDrawer = () => setDrawerOpen(false); - /** ===== me -> groups ===== */ const { data: me } = useMe(); const groups = useMemo(() => { @@ -176,7 +444,6 @@ export default function ListPage() { return list; }, [me?.memberships]); - /** ===== active group (effect 없이 fallback) ===== */ const [activeGroupIdState, setActiveGroupIdState] = useState(undefined); const activeGroupId = activeGroupIdState ?? groups[0]?.id ?? 0; @@ -185,19 +452,28 @@ export default function ListPage() { [groups, activeGroupId], ); - /** ===== group detail ===== */ - const { data: groupDetail } = useGroupDetail(activeGroupId); + const groupDetailQuery = useGroupDetail(activeGroupId) as unknown as { + data?: { taskLists?: Array<{ id: number; name: string; displayIndex: number }> }; + isLoading?: boolean; + isFetching?: boolean; + }; + const groupDetail = groupDetailQuery.data; + const isGroupDetailLoading = !!groupDetailQuery.isLoading; + const isGroupDetailFetching = !!groupDetailQuery.isFetching; + const isGroupDetailReady = !!groupDetail && !isGroupDetailLoading && !isGroupDetailFetching; const taskLists = useMemo(() => groupDetail?.taskLists ?? [], [groupDetail?.taskLists]); - // const members = useMemo(() => groupDetail?.members ?? [], [groupDetail?.members]); // 필요시 사용 - /** ===== dates ===== */ const [viewDate, setViewDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(new Date()); const selectedDateKey = useMemo(() => toDateKey(selectedDate), [selectedDate]); - const selectedDateIso = useMemo(() => toIsoAtStartOfDay(selectedDate), [selectedDate]); + const selectedDateIso = useMemo(() => toLocalNoonIso(selectedDate), [selectedDate]); + + const [calendarOpen, setCalendarOpen] = useState(false); + const calendarWrapRef = useRef(null); + const toggleCalendar = () => setCalendarOpen((p) => !p); + const closeCalendar = () => setCalendarOpen(false); - /** ===== selected taskList (effect 없이 fallback) ===== */ const firstTaskListId = useMemo(() => { const sorted = [...taskLists].sort((a, b) => a.displayIndex - b.displayIndex); return sorted[0]?.id ?? 0; @@ -206,23 +482,38 @@ export default function ListPage() { const [selectedTaskListIdState, setSelectedTaskListIdState] = useState( undefined, ); - const selectedTaskListId = selectedTaskListIdState ?? firstTaskListId; - const selectedTodoKey = selectedTaskListId ? `taskList-${selectedTaskListId}` : ''; + useEffect(() => { + queueMicrotask(() => setSelectedTaskListIdState(undefined)); + }, [activeGroupId]); + + const selectedTaskListId = selectedTaskListIdState ?? firstTaskListId; + const selectedTodoKey = selectedTaskListId ? `taskList-${selectedTaskListId}` : 'taskList-0'; - /** ===== tasks by date ===== */ const { data: taskListByDate } = useTaskListByDate({ groupId: activeGroupId, taskListId: selectedTaskListId, dateIso: selectedDateIso, }); - const tasks = useMemo(() => taskListByDate?.tasks ?? [], [taskListByDate?.tasks]); - /** ===== TodoCard preview (선택된 리스트만 3개 preview) ===== */ const todoCardsWithPreview: TodoCardData[] = useMemo(() => { + if (!isGroupDetailReady) return []; + 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 sorted.map((tl) => { const isSelected = tl.id === selectedTaskListId; const previewSrc = isSelected ? tasks : []; @@ -240,15 +531,30 @@ export default function ListPage() { items: preview, }; }); - }, [taskLists, tasks, selectedTaskListId]); + }, [taskLists, tasks, selectedTaskListId, isGroupDetailReady]); - const selectedTodo = useMemo( - () => todoCardsWithPreview.find((c) => c.key === selectedTodoKey) ?? todoCardsWithPreview[0], - [todoCardsWithPreview, selectedTodoKey], - ); + const selectedTodo = useMemo(() => { + if (!isGroupDetailReady) + return { id: 0, key: 'taskList-0', title: '', expanded: false, items: [] }; + + return ( + todoCardsWithPreview.find((c) => c.key === selectedTodoKey) ?? + todoCardsWithPreview[0] ?? { + id: 0, + key: 'taskList-0', + title: '제목 없음', + expanded: false, + items: [], + } + ); + }, [todoCardsWithPreview, selectedTodoKey, isGroupDetailReady]); - /** ===== selected task (effect 없이 안전 fallback) ===== */ const [selectedTaskIdState, setSelectedTaskIdState] = useState(undefined); + + useEffect(() => { + queueMicrotask(() => setSelectedTaskIdState(undefined)); + }, [selectedTaskListId, selectedDateIso]); + const selectedTaskId = selectedTaskIdState && tasks.some((t) => t.id === selectedTaskIdState) ? selectedTaskIdState @@ -259,17 +565,19 @@ export default function ListPage() { [tasks, selectedTaskId], ); - /** ===== modals: taskList ===== */ const [addTodoOpen, setAddTodoOpen] = useState(false); const [todoEditTarget, setTodoEditTarget] = useState(null); + const [todoNameDraft, setTodoNameDraft] = useState(''); const openTodoCreate = () => { setTodoEditTarget(null); + setTodoNameDraft(''); setAddTodoOpen(true); }; const openTodoEdit = (card: TodoCardData) => { setTodoEditTarget(card); + setTodoNameDraft(card.title === '제목 없음' ? '' : card.title); setAddTodoOpen(true); }; @@ -278,16 +586,14 @@ export default function ListPage() { const deleteTaskList = useDeleteTaskList(); const handleSubmitTodoModal = async () => { - const input = document.querySelector('input[name="todo"]'); - const name = (input?.value ?? '').trim(); + const name = todoNameDraft.trim(); if (!name) return; - if (!activeGroupId) return; - if (!todoEditTarget) { - const created = await createTaskList.mutateAsync({ groupId: activeGroupId, name }); - // 생성 후 그 리스트로 선택 - setSelectedTaskListIdState(created.id); + if (!todoEditTarget || todoEditTarget.id === 0) { + const createdUnknown = await createTaskList.mutateAsync({ groupId: activeGroupId, name }); + const created = createdUnknown as unknown as CreateTaskListResult; + if (created?.id) setSelectedTaskListIdState(created.id); } else { await updateTaskList.mutateAsync({ groupId: activeGroupId, @@ -299,9 +605,9 @@ export default function ListPage() { setAddTodoOpen(false); setTodoEditTarget(null); + setTodoNameDraft(''); }; - /** ===== modals: task ===== */ const [calendarModalOpen, setCalendarModalOpen] = useState(false); const [taskEditTarget, setTaskEditTarget] = useState(null); @@ -346,10 +652,14 @@ export default function ListPage() { ); const start = new Date(base); - const time = (payload.startTime ?? '09:00').split(':'); - const hh = Number(time[0] ?? 9); - const mm = Number(time[1] ?? 0); - start.setHours(Number.isFinite(hh) ? hh : 9, Number.isFinite(mm) ? mm : 0, 0, 0); + const { hh, mm } = parseTimeToHM(payload.startTime); + start.setHours(hh, mm, 0, 0); + + const rawRepeatDays = (payload as unknown as { repeatDays?: unknown }).repeatDays; + const weekDays = freq === 'WEEKLY' ? toApiWeekDays(rawRepeatDays) : undefined; + const monthDay = freq === 'MONTHLY' ? start.getDate() : undefined; + + const startIso = toIsoWithOffset(start); if (!taskEditTarget) { await createTask.mutateAsync({ @@ -357,24 +667,32 @@ export default function ListPage() { taskListId: selectedTaskListId, name: title, description: memo, - startDate: start.toISOString(), + startDate: startIso, frequencyType: freq, + weekDays, + monthDay, }); + await invalidateCurrentList(); setCalendarModalOpen(false); setTaskEditTarget(null); return; } + const body = { + name: title, + description: memo, + startDate: startIso, + frequencyType: freq, + weekDays, + monthDay, + }; + await patchTask.mutateAsync({ groupId: activeGroupId, taskListId: selectedTaskListId, taskId: taskEditTarget.id, - body: { - name: title, - description: memo, - date: start.toISOString(), - }, + body: body as unknown as never, }); await invalidateCurrentList(); @@ -382,37 +700,63 @@ export default function ListPage() { setTaskEditTarget(null); }; - /** ===== kebab close outside ===== */ const [openedTaskMenuId, setOpenedTaskMenuId] = useState(null); const [openedTodoMenuKey, setOpenedTodoMenuKey] = useState(null); const [todoListDropdownOpen, setTodoListDropdownOpen] = useState(false); + const [teamMenuOpen, setTeamMenuOpen] = useState(false); + useEffect(() => { const onDoc = (ev: globalThis.MouseEvent) => { const t = ev.target as HTMLElement | null; if (!t) return; - if (openedTaskMenuId !== null && !t.closest(`.${styles.taskMenu}`)) setOpenedTaskMenuId(null); - if (openedTodoMenuKey !== null && !t.closest(`.${styles.kebabMenu}`)) - setOpenedTodoMenuKey(null); - if (todoListDropdownOpen && !t.closest(`.${styles.todoListDropdown}`)) + if (calendarOpen) { + if (t.closest(`.${styles.calendarPopover}`) || t.closest(`.${styles.calendarBtn}`)) { + // no-op + } else { + setCalendarOpen(false); + } + } + + if (t.closest(`.${styles.taskMenu}`) || t.closest(`.${styles.kebabMenu}`)) return; + if (isKebabTrigger(t)) return; + + if (openedTaskMenuId !== null) setOpenedTaskMenuId(null); + if (openedTodoMenuKey !== null) setOpenedTodoMenuKey(null); + + if (todoListDropdownOpen) { + if (t.closest(`.${styles.todoListDropdown}`)) return; + + const labeled = t.closest('[aria-label]') as HTMLElement | null; + const label = (labeled?.getAttribute('aria-label') ?? '').toLowerCase(); + if (label.includes('할 일 목록 열기')) return; + setTodoListDropdownOpen(false); + } + + if (teamMenuOpen) { + if (t.closest(`.${styles.teamMenu}`)) return; + if (t.closest('[class*="TeamHeader-module__"][class*="settingBig"]')) return; + setTeamMenuOpen(false); + } }; document.addEventListener('click', onDoc); return () => document.removeEventListener('click', onDoc); - }, [openedTaskMenuId, openedTodoMenuKey, todoListDropdownOpen]); + }, [openedTaskMenuId, openedTodoMenuKey, todoListDropdownOpen, teamMenuOpen, calendarOpen]); const handleSelectTodoList = (key: string) => { const id = Number(key.replace('taskList-', '')); if (!Number.isFinite(id) || id <= 0) return; + setSelectedTaskListIdState(id); setTodoListDropdownOpen(false); setOpenedTodoMenuKey(null); - // task 선택은 fallback 로직이 자동 처리 + setOpenedTaskMenuId(null); + setSelectedTaskIdState(undefined); }; - /** ===== detail overlay ===== */ const [detailMounted, setDetailMounted] = useState(false); const [detailOpen, setDetailOpen] = useState(false); @@ -450,6 +794,11 @@ export default function ListPage() { }; const handleOpenDetail = (taskId: number) => { + if (!taskId) return; + + setOpenedTaskMenuId(null); + setOpenedTodoMenuKey(null); + if (detailMounted && detailOpen && taskId === selectedTaskId) { closeDetail(); return; @@ -458,7 +807,6 @@ export default function ListPage() { openDetail(); }; - /** ===== detail comments ===== */ const { data: detailComments = [] } = useTaskComments(selectedTaskId); const createComment = useCreateTaskComment(); @@ -470,7 +818,6 @@ export default function ListPage() { }; }, [me]); - /** ===== profile image (no ) ===== */ const profile40 = useMemo(() => { const url = me?.image || ''; return ( @@ -505,14 +852,11 @@ export default function ListPage() { ); }, [me?.image]); - /** ===== handlers ===== */ const handleSelectGroup = (groupId: number) => { setActiveGroupIdState(groupId); - // 그룹 변경 시 selection state 초기화 (effect 없이) - setSelectedTaskListIdState(undefined); - setSelectedTaskIdState(undefined); setOpenedTaskMenuId(null); setOpenedTodoMenuKey(null); + setTeamMenuOpen(false); closeDrawer(); }; @@ -523,9 +867,7 @@ export default function ListPage() { groupId: activeGroupId, taskListId: selectedTaskListId, taskId, - body: { - doneAt: nextDone ? new Date().toISOString() : null, - }, + body: { doneAt: nextDone ? new Date().toISOString() : null } as unknown as never, }); await invalidateCurrentList(); @@ -545,8 +887,122 @@ export default function ListPage() { if (detailMounted && selectedTaskId === taskId) closeDetail(); }; + const deleteGroup = async (groupId: number) => { + console.warn('TODO: deleteGroup API not wired', groupId); + await qc.invalidateQueries({ queryKey: ['me'] }); + }; + + const handleClickTeamEdit = () => { + if (!activeGroupId) return; + setTeamMenuOpen(false); + router.push(EDIT_TEAM_PATH(activeGroupId)); + }; + + const handleClickTeamDelete = async () => { + if (!activeGroupId) return; + setTeamMenuOpen(false); + + const ok = window.confirm('정말 팀을 삭제할까요? (삭제 시 복구 불가)'); + 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); + const selectedFreq = getTaskFrequency(selectedTask); + + const handleTeamHeaderClickCapture = (e: ReactMouseEvent) => { + const t = e.target as HTMLElement | null; + if (!t) return; + + if (t.closest('[class*="TeamHeader-module__"][class*="settingBig"]')) { + e.preventDefault(); + e.stopPropagation(); + setTeamMenuOpen((p) => !p); + } + }; + + const calendarInitialValues = useMemo(() => { + if (!taskEditTarget) { + return { + startDate: selectedDate, + startTime: '09:00', + repeatType: 'none', + repeatDays: [], + }; + } + + const iso = normalizeIsoForUi(getTaskIso(taskEditTarget), selectedDateKey); + const dt = new Date(iso); + const freq = getTaskFrequency(taskEditTarget); + const startDate = Number.isNaN(dt.getTime()) ? selectedDate : dt; + + return { + todoTitle: taskEditTarget.name ?? '', + memo: taskEditTarget.description ?? '', + startDate, + startTime: formatHHmmFromIso(iso) || '09:00', + repeatType: + freq === 'DAILY' + ? 'daily' + : freq === 'WEEKLY' + ? 'weekly' + : freq === 'MONTHLY' + ? 'monthly' + : 'none', + repeatDays: freq === 'WEEKLY' ? toModalRepeatDays(getTaskWeekDays(taskEditTarget)) : [], + }; + }, [taskEditTarget, selectedDate, selectedDateKey]); + return (
+ + {isPc ? (
- {(isCollapsed) => ( + teamSelect={(isCollapsed) => + !isCollapsed ? ( + } + label="팀 선택" + isSelected={false} + /> + ) : null + } + addButton={(isCollapsed) => ( <> - {groups.map((g) => ( - } - label={g.name} - iconOnly={isCollapsed} - isActive={g.id === activeGroupId} - onClick={() => handleSelectGroup(g.id)} - /> - ))} - {!isCollapsed ? ( { - /* 라우팅 필요하면 여기 */ - }} + onClick={() => router.push(ADD_TEAM_PATH)} /> ) : null}
} + icon={ + + } 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} @@ -604,37 +1087,35 @@ export default function ListPage() { {isMobileUi ? ( - <> - {groups.map((g) => ( - } - label={g.name} - isActive={g.id === activeGroupId} - onClick={() => handleSelectGroup(g.id)} - /> - ))} - - - -
- - } - label="자유게시판" - onClick={closeDrawer} - /> - + {drawerContent}
) : null}
-
+
+ + {teamMenuOpen ? ( +
+ + +
+ ) : null}
- {/* Mobile Todo dropdown */}
할 일 @@ -648,6 +1129,7 @@ export default function ListPage() { onClick={(e) => { e.stopPropagation(); setTodoListDropdownOpen((prev) => !prev); + setOpenedTodoMenuKey(null); }} > @@ -657,18 +1139,60 @@ export default function ListPage() {
- {selectedTodo ? ( - {}} - onItemCheckedChange={() => {}} - /> - ) : null} + { + if (selectedTodo.id === 0) { + openTodoCreate(); + return; + } + setOpenedTodoMenuKey((prev) => + prev === selectedTodo.key ? null : selectedTodo.key, + ); + setTodoListDropdownOpen(false); + }} + onItemCheckedChange={() => {}} + />
+ {openedTodoMenuKey === selectedTodo.key && selectedTodo.id !== 0 ? ( +
+ + +
+ ) : null} + {todoListDropdownOpen ? (
handleSelectTodoList(c.key)} + onClick={() => { + if (c.id === 0) { + openTodoCreate(); + setTodoListDropdownOpen(false); + return; + } + handleSelectTodoList(c.key); + }} > {c.title} @@ -699,7 +1230,6 @@ export default function ListPage() {
- {/* LEFT (PC) */}

할 일

@@ -708,22 +1238,36 @@ export default function ListPage() {
handleSelectTodoList(card.key)} + onClick={(e: ReactMouseEvent) => { + const t = e.target as HTMLElement | null; + if (!t) return; + if (isKebabTrigger(t)) return; + + if (card.id === 0) { + openTodoCreate(); + return; + } + handleSelectTodoList(card.key); + }} >
- setOpenedTodoMenuKey((prev) => (prev === card.key ? null : card.key)) - } + onKebabClick={() => { + if (card.id === 0) { + openTodoCreate(); + return; + } + setOpenedTodoMenuKey((prev) => (prev === card.key ? null : card.key)); + }} onItemCheckedChange={() => {}} />
- {openedTodoMenuKey === card.key ? ( + {openedTodoMenuKey === card.key && card.id !== 0 ? (
- {/* RIGHT */}

- {selectedTodo?.title ?? '할 일을 입력해주세요..'} + {isGroupDetailReady ? selectedTodo.title : ''}

@@ -792,9 +1335,31 @@ export default function ListPage() { />
- +
+ + + {calendarOpen ? ( +
+ { + if (v) setSelectedDate(v); + closeCalendar(); + }} + /> +
+ ) : null} +
@@ -806,69 +1371,101 @@ export default function ListPage() { />
-
- {tasks.length === 0 ? ( -
선택한 날짜에 할 일이 없습니다.
- ) : ( - tasks.map((task) => ( + {!isGroupDetailReady ? ( +
+
불러오는 중...
+
+ ) : ( +
+ {!hasRealTaskList || tasks.length === 0 ? (
{ - const t = e.target as HTMLElement | null; - if (isOpenDetailBlockedTarget(t)) return; - handleOpenDetail(task.id); - }} + onClick={() => (hasRealTaskList ? openTaskCreate() : openTodoCreate())} + role="button" + tabIndex={0} > -
- { - await handleToggleDone(task.id, checked); - }} - onKebabClick={() => - setOpenedTaskMenuId((prev) => (prev === task.id ? null : task.id)) - } - /> - - {openedTaskMenuId === task.id ? ( -
    -
  • - -
  • -
  • - -
  • -
- ) : null} -
+ {}} + onKebabClick={() => {}} + />
- )) - )} -
+ ) : ( + tasks.map((task) => { + const taskIso = normalizeIsoForUi(getTaskIso(task), selectedDateKey); + + return ( +
) => { + const t = e.target as HTMLElement | null; + if (isOpenDetailBlockedTarget(t)) return; + handleOpenDetail(task.id); + }} + > +
+ { + await handleToggleDone(task.id, checked); + }} + onKebabClick={() => { + setOpenedTaskMenuId((prev) => + prev === task.id ? null : task.id, + ); + }} + /> + + {openedTaskMenuId === task.id ? ( +
    +
  • + +
  • +
  • + +
  • +
+ ) : null} +
+
+ ); + }) + )} +
+ )}
@@ -878,7 +1475,6 @@ export default function ListPage() {
- {/* ✅ Detail Overlay */} {detailMounted && selectedTask ? (
{ await handleToggleDone(selectedTask.id, !selectedTask.doneAt); @@ -925,13 +1521,21 @@ export default function ListPage() { onClose={() => { setAddTodoOpen(false); setTodoEditTarget(null); + setTodoNameDraft(''); }} onSubmit={handleSubmitTodoModal} text={{ title: '할 일 목록', - submitLabel: todoEditTarget ? '수정하기' : '만들기', + submitLabel: todoEditTarget && todoEditTarget.id !== 0 ? '수정하기' : '만들기', inputPlaceholder: '할 일을 입력하세요', }} + input={{ + props: { + value: todoNameDraft, + onChange: (e) => setTodoNameDraft(e.target.value), + autoFocus: true, + }, + }} closeOptions={{ overlayClick: true, escape: true }} /> @@ -946,23 +1550,7 @@ export default function ListPage() { title: taskEditTarget ? '할 일 수정하기' : '할 일 만들기', submitLabel: taskEditTarget ? '수정하기' : '만들기', }} - initialValues={ - taskEditTarget - ? { - todoTitle: taskEditTarget.name ?? '', - memo: taskEditTarget.description ?? '', - startDate: selectedDate, - startTime: '09:00', - repeatType: 'none', - repeatDays: [], - } - : { - startDate: selectedDate, - startTime: '09:00', - repeatType: 'none', - repeatDays: [], - } - } + initialValues={calendarInitialValues as unknown as never} closeOptions={{ overlayClick: true, escape: true }} />