diff --git a/src/app/(root)/list/hooks/queries.ts b/src/app/(root)/list/hooks/queries.ts new file mode 100644 index 0000000..5b58a11 --- /dev/null +++ b/src/app/(root)/list/hooks/queries.ts @@ -0,0 +1,325 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +/** ===== helpers ===== */ +function proxy(path: string) { + const p = path.startsWith('/') ? path.slice(1) : path; + return `/api/proxy/${p}`; +} + +async function assertOk(res: Response, message: string) { + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`${message} (status: ${res.status}) ${text}`); + } +} + +async function fetchJson(path: string, init?: RequestInit, message = '요청 실패'): Promise { + const res = await fetch(proxy(path), { + ...init, + headers: { + ...(init?.headers ?? {}), + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + }, + }); + await assertOk(res, message); + return (await res.json()) as T; +} + +async function fetchVoid(path: string, init?: RequestInit, message = '요청 실패'): Promise { + const res = await fetch(proxy(path), init); + await assertOk(res, message); +} + +/** ===== types (swagger 기반 최소 필요 필드) ===== */ +export type ApiFrequency = 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY'; + +export type Group = { + id: number; + name: string; + image: string | null; + createdAt: string; + updatedAt: string; +}; + +export type Membership = { + group: Group; + role: 'ADMIN' | 'MEMBER'; + userImage: string; + userEmail: string; + userName: string; + groupId: number; + userId: number; +}; + +export type UserResponse = { + teamId: string; + image: string; + nickname: string; + updatedAt: string; + createdAt: string; + email: string; + id: number; + memberships: Membership[]; +}; + +export type GroupMember = { + id: number; + email: string; + nickname: string; + image: string | null; +}; + +export type TaskList = { + id: number; + name: string; + displayIndex: number; + groupId: number; + createdAt: string; + updatedAt: string; +}; + +export type Task = { + id: number; + name: string; + description: string | null; + date: string; // ISO + doneAt: string | null; + frequency: ApiFrequency; + commentCount: number; + writer?: { id: number; nickname: string; image: string | null } | null; +}; + +export type TaskListByDateResponse = { + id: number; + name: string; + displayIndex: number; + groupId: number; + createdAt: string; + updatedAt: string; + tasks: Task[]; +}; + +export type GroupDetailResponse = { + id: number; + name: string; + image: string | null; + createdAt: string; + updatedAt: string; + taskLists: TaskList[]; + members: GroupMember[]; +}; + +export type Comment = { + id: number; + content: string; + createdAt: string; + updatedAt: string; + taskId: number; + userId: number; + user: { id: number; nickname: string; image: string | null }; +}; + +/** ===== queries ===== */ +export function useMe() { + return useQuery({ + queryKey: ['me'], + queryFn: () => + fetchJson('user', undefined, '유저 정보를 불러오는데 실패했습니다.'), + staleTime: 30_000, + }); +} + +export function useGroupDetail(groupId: number) { + return useQuery({ + queryKey: ['groupDetail', groupId], + enabled: groupId > 0, + queryFn: () => + fetchJson( + `groups/${groupId}`, + undefined, + '그룹 정보를 불러오는데 실패했습니다.', + ), + staleTime: 10_000, + }); +} + +export function useTaskListByDate(params: { + groupId: number; + taskListId: number; + dateIso: string; +}) { + const { groupId, taskListId, dateIso } = params; + + return useQuery({ + queryKey: ['taskListByDate', groupId, taskListId, dateIso], + enabled: groupId > 0 && taskListId > 0 && !!dateIso, + queryFn: () => + fetchJson( + `groups/${groupId}/task-lists/${taskListId}?date=${encodeURIComponent(dateIso)}`, + { cache: 'no-store' }, + '할 일 목록을 불러오는데 실패했습니다.', + ), + }); +} + +export function useTaskComments(taskId: number) { + return useQuery({ + queryKey: ['taskComments', taskId], + enabled: taskId > 0, + queryFn: () => + fetchJson( + `tasks/${taskId}/comments`, + undefined, + '댓글을 불러오는데 실패했습니다.', + ), + }); +} + +/** ===== mutations ===== */ +export function useCreateTaskList() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { groupId: number; name: string }) => + fetchJson( + `groups/${vars.groupId}/task-lists`, + { method: 'POST', body: JSON.stringify({ name: vars.name }) }, + '할 일 목록 생성에 실패했습니다.', + ), + onSuccess: async (_, vars) => { + await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] }); + }, + }); +} + +export function useUpdateTaskList() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { groupId: number; taskListId: number; name: string }) => + 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] }); + }, + }); +} + +export function useDeleteTaskList() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { groupId: number; taskListId: number }) => + 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] }); + }, + }); +} + +/** + * TaskRecurringCreateDto 기반 + * - ONCE도 여기로 POST /tasks + * - weekly/monthly면 weekDays/monthDay 전달 가능 + */ +export function useCreateTask() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { + groupId: number; + taskListId: number; + name: string; + description?: string; + startDate: string; // ISO + frequencyType: ApiFrequency; + weekDays?: string[]; // ['MONDAY'...] + monthDay?: number; + }) => + fetchJson( + `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks`, + { + method: 'POST', + body: JSON.stringify({ + name: vars.name, + description: vars.description ?? '', + startDate: vars.startDate, + frequencyType: vars.frequencyType, + weekDays: vars.weekDays, + monthDay: vars.monthDay, + }), + }, + '할 일 생성에 실패했습니다.', + ), + onSuccess: async (_, vars) => { + await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); + }, + }); +} + +export function usePatchTask() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { + groupId: number; + taskListId: number; + taskId: number; + body: { name?: string; description?: string; date?: string; doneAt?: string | null }; + }) => + 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: ['taskComments', vars.taskId] }); + }, + }); +} + +export function useDeleteTask() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { groupId: number; taskListId: number; taskId: number }) => + 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: ['taskComments', vars.taskId] }); + }, + }); +} + +export function useCreateTaskComment() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { taskId: number; content: string }) => + 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 new file mode 100644 index 0000000..e562f21 --- /dev/null +++ b/src/app/(root)/list/list.module.css @@ -0,0 +1,837 @@ +: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; +} + +/* ✅ TeamHeader 패딩 wrapper (모바일/태블릿만) */ +.teamHeaderPad { + padding: 0; +} +@media (max-width: 1024px) { + .teamHeaderPad { + padding: 0 12px; + box-sizing: border-box; + } +} + +/* ✅ TeamHeader: PC에서만 가운데 정렬 */ +@media (min-width: 1025px) { + :global(.TeamHeader-module__H3kcRq__container) { + width: 100%; + max-width: 1200px; + margin-left: auto !important; + margin-right: auto !important; + } +} +@media (max-width: 1024px) { + :global(.TeamHeader-module__H3kcRq__container) { + margin-left: 0 !important; + margin-right: 0 !important; + max-width: none !important; + } +} + +/* ===== Desktop Sidebar ===== */ +.desktopSidebar { + flex-shrink: 0; + align-self: stretch; + display: flex; + height: auto; + min-height: 100%; +} + +/* ✅ 사이드바 접힘시 잘림(닫기 버튼) 해결: aside는 visible, 내용 영역만 hidden */ +:global([class*='Sidebar-module__'][class*='sidebar']) { + overflow: visible !important; +} +:global([class*='Sidebar-module__'][class*='content']) { + overflow: hidden !important; +} +:global([class*='Sidebar-module__'] button), +:global([class*='Sidebar-module__'] a), +:global([class*='Sidebar-module__'] span), +:global([class*='Sidebar-module__'] p) { + max-width: 100% !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; +} + +/* ===== Mobile GNB ===== */ +.mobileGnb { + display: none; +} + +/* ✅ 1024px 이상에서는 데스크탑 Sidebar가 보이도록 강제 (Sidebar 컴포넌트의 1199px 이하 숨김을 override) */ +@media (min-width: 1024px) and (max-width: 1199px) { + .desktopSidebar { + display: flex !important; + } + .mobileGnb { + display: none !important; + } + :global([class*='Sidebar-module__'][class*='sidebar']) { + display: flex !important; + } +} + +@media (max-width: 1023px) { + .desktopSidebar { + display: none; + } + .mobileGnb { + display: block; + position: fixed; + top: 0; + left: 0; + right: 0; + background: var(--color-background-inverse, #fff); + padding-top: env(safe-area-inset-top); + } +} + +/* ===== Main contents ===== */ +.mainContents { + flex: 1; + display: flex; + justify-content: center; + padding: 120px 85px 80px; + background: var(--color-background-secondary, #f5f6f8); + box-sizing: border-box; + min-height: 100dvh; + min-width: 0; +} + +@media (max-width: 1023px) { + .mainContents { + width: 100%; + padding: 0; + padding-top: calc(60px + env(safe-area-inset-top)); + box-sizing: border-box; + } +} + +.stage { + width: min(90vw, 1200px); + min-width: 0; +} + +.body { + display: flex; + gap: 25px; + margin-top: 46px; + align-items: flex-start; + justify-content: center; + min-width: 0; +} + +/* ===== LEFT (PC Only) ===== */ +.leftCol { + width: 270px; + flex-shrink: 0; + display: flex; + flex-direction: column; +} + +.leftTitle { + margin: 0 0 16px 0; + font-size: 18px; + font-weight: 800; + color: #0f172a; +} + +.todoList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.todoCardWrap { + position: relative; + width: 100%; +} + +.todoCardShell { + width: 100%; +} + +.todoCardShellInner { + width: 100%; +} + +.todoCardShellCollapsed { + height: 44px; + overflow: hidden; + border-radius: 12px; +} + +.leftAddWrap { + margin-top: 38px; + display: flex; + justify-content: center; +} + +.leftAddBtn { + height: 44px; + padding: 0 18px; + border-radius: 999px; + border: 1px solid #5189fa; + color: #5189fa; + background: #fff; + font-weight: 700; + cursor: pointer; +} + +/* ===== RIGHT ===== */ +.rightWrap { + position: relative; + width: min(819px, 100%); + max-width: 819px; + flex: 0 1 819px; + min-width: 0; + background: #ffffff; + border-radius: 24px; + overflow: visible; + min-height: 768px; +} + +@media (min-width: 744px) and (max-width: 1024px) { + .rightWrap { + min-height: 800px; + } +} + +@media (max-width: 744px) { + .rightWrap { + min-height: 618px; + } +} + +.rightPanel { + background: #ffffff; + border-radius: 20px; + padding-top: 46px; + padding-left: 43px; + padding-right: 42px; + padding-bottom: 22px; +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; +} + +.panelTitle { + margin: 0; + font-size: 18px; + font-weight: 800; + color: #0f172a; +} + +.panelControls { + display: flex; + align-items: center; + gap: 8px; +} + +.yearMonth { + font-size: 12px; + font-weight: 700; + color: #0f172a; + white-space: nowrap; +} + +.arrowGroup { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.calendarBtn { + width: 24px; + height: 24px; + border: none; + background: transparent; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.weekBar { + margin-top: 16px; + margin-bottom: 24px; +} + +.taskList { + display: flex; + flex-direction: column; + gap: 12px; + position: relative; + overflow: visible; +} + +.taskRowClick { + cursor: pointer; +} + +/* FloatingButton */ +.fab { + position: absolute; + z-index: 4; +} +@media (min-width: 1025px) { + .fab { + top: 262px; + right: -28px; + bottom: auto; + left: auto; + } +} +@media (max-width: 1024px) { + .fab { + right: 14px; + bottom: 21px; + top: auto; + left: auto; + } +} + +/* ===== 모바일/태블릿 “할 일” 섹션 ===== */ +.mobileTodoSection { + display: none; +} + +.mobileTodoLabel { + font-size: 14px; + font-weight: 800; + color: #64748b; +} + +/* ✅ 모바일/태블릿: 투두카드 폭 고정(모바일 180 / 태블릿 240) + 버튼 같은 줄 */ +@media (max-width: 1024px) { + .mobileTodoRow { + display: flex !important; + align-items: center !important; + width: 100% !important; + min-width: 0 !important; + } + + /* ✅ 한 줄 컨테이너 */ + .mobileTodoInlineCard { + display: flex !important; + align-items: center !important; + gap: 12px !important; + width: 100% !important; + min-width: 0 !important; + } + + /* ✅ 투두카드 영역: 모바일 기본 180 고정 */ + .todoCardWrap { + width: 180px !important; + flex: 0 0 180px !important; /* grow=0, shrink=0 */ + min-width: 180px !important; + max-width: 180px !important; + } + + /* ✅ 버튼은 오른쪽 고정 */ + .mobileAddBtnWrap { + margin-left: auto !important; + width: 112px !important; + flex: 0 0 112px !important; + } +} + +/* ✅ 태블릿만: 투두카드 폭 240 고정 */ +@media (min-width: 744px) and (max-width: 1024px) { + .todoCardWrap { + width: 240px !important; + flex: 0 0 240px !important; + min-width: 240px !important; + max-width: 240px !important; + } +} + +/* ✅ TodoCard 자체는 부모폭 100% */ +@media (max-width: 1024px) { + .todoCardShell, + .todoCardShellInner { + width: 100% !important; + } + + .mobileTodoSection :global(.TodoCard-module__SrewtW__card) { + width: 100% !important; + } +} + +.mobileAddBtnWrap { + margin-left: auto; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; + width: 112px; + height: 40px; + border-radius: 40px; +} + +.mobileAddBtnWrap :global([class*='GnbAddButton-module__'][class*='button']) { + width: 100% !important; + height: 100% !important; + border-radius: 40px !important; + border: 1px solid #3b82f6 !important; + background: transparent !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 6px !important; + padding: 0 12px !important; +} + +.mobileAddBtnWrap :global([class*='GnbAddButton-module__'][class*='icon']) { + width: 16px !important; + height: 16px !important; +} + +.mobileAddBtnWrap :global([class*='GnbAddButton-module__'][class*='text']) { + font-size: 14px !important; + font-weight: 500 !important; + line-height: 17px !important; +} + +/* Tablet/Mobile */ +@media (max-width: 1024px) { + .stage { + width: 100%; + max-width: 1024px; + margin-top: 32px; /* ✅ 요청 */ + } + + .body { + flex-direction: column; + gap: 0; + justify-content: flex-start; + align-items: stretch; + margin-top: 15px; /* ✅ 요청 */ + } + + .leftCol { + display: none; + } + + .mobileTodoSection { + display: block; + padding-top: 12px; + padding-bottom: 22px; + padding-left: 16px; + padding-right: 19px; + box-sizing: border-box; + margin-top: 0; + } + + .mobileTodoLabel { + display: block; + margin-bottom: 8px; + } + + .mobileTodoRow { + margin-top: 8px; + } + + .rightWrap { + width: 100%; + max-width: 1024px; + flex: 0 0 auto; + background: #ffffff; + border-radius: 0; /* ✅ 요청: 테블릿 radius 제거 */ + overflow: visible; + min-width: 0; + } + + .rightPanel { + border-radius: 0; + padding-left: 0; + padding-right: 0; + padding-top: 38px; + padding-bottom: 38px; + background: transparent; + } + + .panelHeader, + .weekBar, + .taskList { + padding-left: 16px; + padding-right: 16px; + box-sizing: border-box; + } + + .weekBar { + margin-top: 16px; + margin-bottom: 38px; + } +} + +@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; + top: 10px; + width: 28px; + height: 28px; + border: none; + background: transparent; + cursor: pointer; + z-index: 2; + display: none; +} + +@media (max-width: 1024px) { + .todoArrowBtn { + display: inline-flex; + align-items: center; + justify-content: center; + } +} + +.todoListDropdown { + position: absolute; + left: 0; + top: 47px; + width: 100%; + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); + z-index: 12000; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.todoListOption { + width: 100%; + height: 40px; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + padding: 0 12px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + color: #0f172a; +} + +.todoListOption[aria-selected='true'] { + background: #eff6ff; + color: #2563eb; +} + +/* kebab menu */ +.kebabMenu { + position: absolute; + right: 0; + top: calc(44px + 8.5px); + 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; +} + +.kebabItem { + 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; +} + +.kebabItem:hover { + background: #f1f5f9; +} + +/* task kebab menu */ +.taskMenu { + position: absolute; + top: 52px; + right: -65px; + 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; +} + +.emptyTasks { + padding: 24px 0; + font-size: 14px; + color: #64748b; +} + +/* ===== 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; +} + +/* ✅ TaskDetailCard header 레이아웃을 “X 단독 / 제목+케밥 한줄”로 강제 */ +.detailOverlay :global([class*='TaskDetailCard-module__'][class*='header']) { + display: grid !important; + grid-template-columns: 1fr auto !important; + grid-template-rows: auto auto !important; + grid-template-areas: + 'close close' + 'title kebab' !important; + align-items: center !important; +} + +/* 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']) { + margin-top: 74px !important; + } +} + +@media (max-width: 767px) { + .detailOverlay :global([class*='TaskDetailCard-module__'][class*='title']), + .detailOverlay :global([class*='TaskDetailCard-module__'][class*='kebabWrapper']) { + margin-top: 20px !important; + } +} + +/* ✅ 모바일/태블릿: 애니메이션 제거 + 전체 화면 */ +@media (max-width: 1024px) { + .detailOverlay { + left: 0; + 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; + } +} + +:global(.TaskDetailCard-module__8btaCa__title) { + margin: 0 !important; +} + +@media (max-width: 1024px) { + .taskMenu { + top: 52px !important; + right: 0px !important; + bottom: -50px !important; + } +} + +/* ===== Sidebar Team Select wrapper (PC/Drawer) ===== */ +.sidebarTeamSelectWrap { + position: relative; + width: 100%; +} + +.drawerTeamSelectWrap { + position: relative; + width: 100%; + padding-bottom: 8px; +} +/* ✅ 모바일/태블릿: 투두카드 + "할일 추가" 한 줄로 (구조 변경 없이 CSS만) */ +@media (max-width: 1024px) { + /* 한 줄 컨테이너 */ + .mobileTodoRow { + display: flex !important; + align-items: center !important; + gap: 12px !important; + width: 100% !important; + min-width: 0 !important; + } +} + +/* ✅ 모바일/태블릿: (1번처럼) 투두카드 + "할일 추가" 같은 줄 */ +@media (max-width: 1024px) { + /* row는 그냥 100% */ + .mobileTodoRow { + width: 100% !important; + min-width: 0 !important; + } + + /* ✅ 여기서 핵심: 버튼이 같은 줄에 오도록 flex 컨테이너로 만든다 */ + .mobileTodoInlineCard { + display: flex !important; + align-items: center !important; + gap: 12px !important; + + width: 100% !important; + flex: 1 1 auto !important; + + /* 기존 clamp 고정폭 제거 */ + min-width: 0 !important; + max-width: none !important; + } + + /* 투두카드 영역은 남는 공간 전부 먹게 */ + .todoCardWrap { + flex: 1 1 auto !important; + min-width: 0 !important; + } + + .todoCardShell, + .todoCardShellInner { + width: 100% !important; + } + + /* 버튼은 고정폭 유지하면서 오른쪽 */ + .mobileAddBtnWrap { + margin-left: 0 !important; + flex: 0 0 112px !important; + width: 112px !important; + } +} +@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; + } + + /* ✅ 버튼을 진짜 맨 오른쪽으로 */ + .mobileAddBtnWrap { + margin-left: auto !important; + flex: 0 0 112px !important; + width: 112px !important; + } +} diff --git a/src/app/(root)/list/page.tsx b/src/app/(root)/list/page.tsx new file mode 100644 index 0000000..89bbbbf --- /dev/null +++ b/src/app/(root)/list/page.tsx @@ -0,0 +1,971 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'; +import Image from 'next/image'; +import { useQueryClient } from '@tanstack/react-query'; + +import styles from './list.module.css'; + +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 SidebarButton from '@/components/sidebar/SidebarButton'; +import SidebarAddButton from '@/components/sidebar/SidebarAddButton'; + +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 { + type ApiFrequency, + type Task, + useCreateTask, + useCreateTaskComment, + useCreateTaskList, + useDeleteTask, + useDeleteTaskList, + useGroupDetail, + useMe, + usePatchTask, + useTaskComments, + useTaskListByDate, + useUpdateTaskList, +} from '@/app/(root)/list/hooks/queries'; + +/** ✅ 체크박스/케밥/버튼 클릭이면 디테일 오픈 금지 */ +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') || + v.includes('케밥') + ) { + 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; +} + +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) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d.toISOString(); +} + +function frequencyLabel(freq?: ApiFrequency) { + if (freq === 'DAILY') return '매일반복'; + if (freq === 'WEEKLY') return '매주반복'; + if (freq === 'MONTHLY') return '매월반복'; + return undefined; +} + +function safeDateFromPayload(v: unknown, fallback: Date) { + if (v instanceof Date && !Number.isNaN(v.getTime())) return v; + if (typeof v === 'string' || typeof v === 'number') { + const d = new Date(v); + if (!Number.isNaN(d.getTime())) return d; + } + return fallback; +} + +type TodoCardData = { + id: number; // taskListId + key: string; // `taskList-${id}` + title: string; + expanded: boolean; + items: TodoItem[]; +}; + +export default function ListPage() { + 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); + + /** ===== me -> groups ===== */ + const { data: me } = useMe(); + + const groups = useMemo(() => { + const list = + (me?.memberships ?? []) + .map((m) => m.group) + .filter((g, idx, arr) => arr.findIndex((x) => x.id === g.id) === idx) ?? []; + return list; + }, [me?.memberships]); + + /** ===== active group (effect 없이 fallback) ===== */ + const [activeGroupIdState, setActiveGroupIdState] = useState(undefined); + const activeGroupId = activeGroupIdState ?? groups[0]?.id ?? 0; + + const activeGroup = useMemo( + () => groups.find((g) => g.id === activeGroupId) ?? null, + [groups, activeGroupId], + ); + + /** ===== group detail ===== */ + const { data: groupDetail } = useGroupDetail(activeGroupId); + + 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]); + + /** ===== selected taskList (effect 없이 fallback) ===== */ + const firstTaskListId = useMemo(() => { + const sorted = [...taskLists].sort((a, b) => a.displayIndex - b.displayIndex); + return sorted[0]?.id ?? 0; + }, [taskLists]); + + const [selectedTaskListIdState, setSelectedTaskListIdState] = useState( + undefined, + ); + const selectedTaskListId = selectedTaskListIdState ?? firstTaskListId; + + const selectedTodoKey = selectedTaskListId ? `taskList-${selectedTaskListId}` : ''; + + /** ===== 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(() => { + const sorted = [...taskLists].sort((a, b) => a.displayIndex - b.displayIndex); + + return sorted.map((tl) => { + const isSelected = tl.id === selectedTaskListId; + const previewSrc = isSelected ? tasks : []; + const preview: TodoItem[] = previewSrc.slice(0, 3).map((t) => ({ + id: String(t.id), + text: t.name, + checked: !!t.doneAt, + })); + + return { + id: tl.id, + key: `taskList-${tl.id}`, + title: tl.name, + expanded: false, + items: preview, + }; + }); + }, [taskLists, tasks, selectedTaskListId]); + + const selectedTodo = useMemo( + () => todoCardsWithPreview.find((c) => c.key === selectedTodoKey) ?? todoCardsWithPreview[0], + [todoCardsWithPreview, selectedTodoKey], + ); + + /** ===== selected task (effect 없이 안전 fallback) ===== */ + const [selectedTaskIdState, setSelectedTaskIdState] = useState(undefined); + const selectedTaskId = + selectedTaskIdState && tasks.some((t) => t.id === selectedTaskIdState) + ? selectedTaskIdState + : (tasks[0]?.id ?? 0); + + const selectedTask: Task | null = useMemo( + () => tasks.find((t) => t.id === selectedTaskId) ?? tasks[0] ?? null, + [tasks, selectedTaskId], + ); + + /** ===== modals: taskList ===== */ + const [addTodoOpen, setAddTodoOpen] = useState(false); + const [todoEditTarget, setTodoEditTarget] = useState(null); + + const openTodoCreate = () => { + setTodoEditTarget(null); + setAddTodoOpen(true); + }; + + const openTodoEdit = (card: TodoCardData) => { + setTodoEditTarget(card); + setAddTodoOpen(true); + }; + + const createTaskList = useCreateTaskList(); + const updateTaskList = useUpdateTaskList(); + const deleteTaskList = useDeleteTaskList(); + + const handleSubmitTodoModal = async () => { + const input = document.querySelector('input[name="todo"]'); + const name = (input?.value ?? '').trim(); + if (!name) return; + + if (!activeGroupId) return; + + if (!todoEditTarget) { + const created = await createTaskList.mutateAsync({ groupId: activeGroupId, name }); + // 생성 후 그 리스트로 선택 + setSelectedTaskListIdState(created.id); + } else { + await updateTaskList.mutateAsync({ + groupId: activeGroupId, + taskListId: todoEditTarget.id, + name, + }); + setSelectedTaskListIdState(todoEditTarget.id); + } + + setAddTodoOpen(false); + setTodoEditTarget(null); + }; + + /** ===== modals: task ===== */ + const [calendarModalOpen, setCalendarModalOpen] = useState(false); + const [taskEditTarget, setTaskEditTarget] = useState(null); + + const openTaskCreate = () => { + setTaskEditTarget(null); + setCalendarModalOpen(true); + }; + + const openTaskEdit = (task: Task) => { + setTaskEditTarget(task); + setCalendarModalOpen(true); + }; + + const createTask = useCreateTask(); + const patchTask = usePatchTask(); + const deleteTask = useDeleteTask(); + + const invalidateCurrentList = async () => { + await qc.invalidateQueries({ + queryKey: ['taskListByDate', activeGroupId, selectedTaskListId, selectedDateIso], + }); + }; + + const handleCalendarSubmit = async (payload: CalenderModalSubmitPayload) => { + const title = (payload.todoTitle ?? '').trim(); + const memo = (payload.memo ?? '').trim(); + if (!title) return; + if (!activeGroupId || !selectedTaskListId) return; + + const freq: ApiFrequency = + payload.repeatType === 'daily' + ? 'DAILY' + : payload.repeatType === 'weekly' + ? 'WEEKLY' + : payload.repeatType === 'monthly' + ? 'MONTHLY' + : 'ONCE'; + + const base = safeDateFromPayload( + (payload as unknown as { startDate?: unknown }).startDate, + selectedDate, + ); + + 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); + + if (!taskEditTarget) { + await createTask.mutateAsync({ + groupId: activeGroupId, + taskListId: selectedTaskListId, + name: title, + description: memo, + startDate: start.toISOString(), + frequencyType: freq, + }); + await invalidateCurrentList(); + setCalendarModalOpen(false); + setTaskEditTarget(null); + return; + } + + await patchTask.mutateAsync({ + groupId: activeGroupId, + taskListId: selectedTaskListId, + taskId: taskEditTarget.id, + body: { + name: title, + description: memo, + date: start.toISOString(), + }, + }); + + await invalidateCurrentList(); + setCalendarModalOpen(false); + setTaskEditTarget(null); + }; + + /** ===== kebab close outside ===== */ + const [openedTaskMenuId, setOpenedTaskMenuId] = useState(null); + const [openedTodoMenuKey, setOpenedTodoMenuKey] = useState(null); + const [todoListDropdownOpen, setTodoListDropdownOpen] = 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}`)) + setTodoListDropdownOpen(false); + }; + + document.addEventListener('click', onDoc); + return () => document.removeEventListener('click', onDoc); + }, [openedTaskMenuId, openedTodoMenuKey, todoListDropdownOpen]); + + 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 로직이 자동 처리 + }; + + /** ===== 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); + }; + + const handleOpenDetail = (taskId: number) => { + if (detailMounted && detailOpen && taskId === selectedTaskId) { + closeDetail(); + return; + } + setSelectedTaskIdState(taskId); + openDetail(); + }; + + /** ===== detail comments ===== */ + const { data: detailComments = [] } = useTaskComments(selectedTaskId); + const createComment = useCreateTaskComment(); + + const meWriter = useMemo(() => { + return { + id: me?.id ?? 0, + nickname: me?.nickname ?? '', + image: me?.image ? me.image : null, + }; + }, [me]); + + /** ===== profile image (no ) ===== */ + const profile40 = useMemo(() => { + const url = me?.image || ''; + return ( +
+ ); + }, [me?.image]); + + const profile32 = useMemo(() => { + const url = me?.image || ''; + return ( +
+ ); + }, [me?.image]); + + /** ===== handlers ===== */ + const handleSelectGroup = (groupId: number) => { + setActiveGroupIdState(groupId); + // 그룹 변경 시 selection state 초기화 (effect 없이) + setSelectedTaskListIdState(undefined); + setSelectedTaskIdState(undefined); + setOpenedTaskMenuId(null); + setOpenedTodoMenuKey(null); + closeDrawer(); + }; + + const handleToggleDone = async (taskId: number, nextDone: boolean) => { + if (!activeGroupId || !selectedTaskListId) return; + + await patchTask.mutateAsync({ + groupId: activeGroupId, + taskListId: selectedTaskListId, + taskId, + body: { + doneAt: nextDone ? new Date().toISOString() : null, + }, + }); + + await invalidateCurrentList(); + }; + + const handleDeleteTask = async (taskId: number) => { + if (!activeGroupId || !selectedTaskListId) return; + + await deleteTask.mutateAsync({ + groupId: activeGroupId, + taskListId: selectedTaskListId, + taskId, + }); + + await invalidateCurrentList(); + + if (detailMounted && selectedTaskId === taskId) closeDetail(); + }; + + return ( +
+ {isPc ? ( +
+ + {(isCollapsed) => ( + <> + {groups.map((g) => ( + } + label={g.name} + iconOnly={isCollapsed} + isActive={g.id === activeGroupId} + onClick={() => handleSelectGroup(g.id)} + /> + ))} + + {!isCollapsed ? ( + { + /* 라우팅 필요하면 여기 */ + }} + /> + ) : null} + +
+ + } + label="자유게시판" + iconOnly={isCollapsed} + onClick={() => {}} + /> + + )} +
+
+ ) : null} + + {isMobileUi ? ( +
+ {}} + /> +
+ ) : null} + + {isMobileUi ? ( + + <> + {groups.map((g) => ( + } + label={g.name} + isActive={g.id === activeGroupId} + onClick={() => handleSelectGroup(g.id)} + /> + ))} + + + +
+ + } + label="자유게시판" + onClick={closeDrawer} + /> + +
+ ) : null} + +
+
+
+ +
+ + {/* Mobile Todo dropdown */} +
+ 할 일 + +
+
+
+ + +
+
+ {selectedTodo ? ( + {}} + onItemCheckedChange={() => {}} + /> + ) : null} +
+
+ + {todoListDropdownOpen ? ( +
+ {todoCardsWithPreview.map((c) => ( + + ))} +
+ ) : null} +
+ +
+ +
+
+
+
+ +
+ {/* LEFT (PC) */} +
+

할 일

+ +
+ {todoCardsWithPreview.map((card) => ( +
+
handleSelectTodoList(card.key)} + > +
+ + setOpenedTodoMenuKey((prev) => (prev === card.key ? null : card.key)) + } + onItemCheckedChange={() => {}} + /> +
+
+ + {openedTodoMenuKey === card.key ? ( +
+ + +
+ ) : null} +
+ ))} +
+ +
+ +
+
+ + {/* RIGHT */} +
+
+
+

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

+ +
+ {formatYearMonth(viewDate)} + +
+ setViewDate((d) => addMonths(d, -1))} + /> + setViewDate((d) => addMonths(d, 1))} + /> +
+ + +
+
+ +
+ +
+ +
+ {tasks.length === 0 ? ( +
선택한 날짜에 할 일이 없습니다.
+ ) : ( + tasks.map((task) => ( +
{ + 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} +
+
+ )) + )} +
+
+ +
+ +
+
+
+
+ + {/* ✅ Detail Overlay */} + {detailMounted && selectedTask ? ( +
+
e.stopPropagation()}> + { + await handleToggleDone(selectedTask.id, !selectedTask.doneAt); + }} + onEdit={() => openTaskEdit(selectedTask)} + onDelete={async () => { + await handleDeleteTask(selectedTask.id); + }} + onClose={closeDetail} + onCommentSubmit={async (content) => { + const c = (content ?? '').trim(); + if (!c) return; + + await createComment.mutateAsync({ taskId: selectedTask.id, content: c }); + await invalidateCurrentList(); + }} + /> +
+
+ ) : null} + + { + setAddTodoOpen(false); + setTodoEditTarget(null); + }} + onSubmit={handleSubmitTodoModal} + text={{ + title: '할 일 목록', + submitLabel: todoEditTarget ? '수정하기' : '만들기', + inputPlaceholder: '할 일을 입력하세요', + }} + closeOptions={{ overlayClick: true, escape: true }} + /> + + { + setCalendarModalOpen(false); + setTaskEditTarget(null); + }} + onSubmit={handleCalendarSubmit} + text={{ + 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: [], + } + } + closeOptions={{ overlayClick: true, escape: true }} + /> +
+
+ ); +} diff --git a/src/components/calendar/CalendarButton/CalendarDayButton.module.css b/src/components/calendar/CalendarButton/CalendarDayButton.module.css new file mode 100644 index 0000000..041d832 --- /dev/null +++ b/src/components/calendar/CalendarButton/CalendarDayButton.module.css @@ -0,0 +1,92 @@ +.button { + /* ✅ 핵심: 고정 width 버리고, 7개가 가로폭을 나눠먹게 */ + flex: 1 1 0; + min-width: 0; + + height: 68px; + + border: 1px solid #e2e8f0; + border-radius: 12px; + background: var(--color-background-inverse, #ffffff); + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + + cursor: pointer; + box-sizing: border-box; + + -webkit-tap-highlight-color: transparent; +} + +.weekday { + font-size: 13px; + font-weight: 500; + line-height: 1; + color: var(--color-text-default, #64748b); +} + +.day { + font-size: 20px; + font-weight: 600; + line-height: 1; + color: var(--color-text-primary, #1e293b); +} + +/* ✅ 선택 상태 */ +.selected { + background: var(--color-text-primary, #1e293b); + border-color: var(--color-text-primary, #1e293b); +} + +.selected .weekday, +.selected .day { + color: #ffffff; +} + +.button:not(.selected):hover { + background: var(--color-background-secondary, #f1f5f9); +} + +.button:focus-visible { + outline: 2px solid rgba(81, 137, 250, 0.6); + outline-offset: 2px; +} + +/* ===== Tablet: 744 ~ 1279 ===== */ +@media (min-width: 744px) and (max-width: 1279px) { + .button { + height: 68px; + } + + .weekday { + font-size: 13px; + font-weight: 500; + } + + .day { + font-size: 20px; + font-weight: 600; + } +} + +/* ✅ Mobile: <= 767 (기존 743 끊김 말고 767로 맞춤) */ +@media (max-width: 767px) { + .button { + height: 49px; + border-radius: 12px; + gap: 4px; + } + + .weekday { + font-size: 12px; + font-weight: 500; + } + + .day { + font-size: 14px; + font-weight: 600; + } +} diff --git a/src/components/calendar/CalendarButton/CalendarDayButton.tsx b/src/components/calendar/CalendarButton/CalendarDayButton.tsx new file mode 100644 index 0000000..702ae29 --- /dev/null +++ b/src/components/calendar/CalendarButton/CalendarDayButton.tsx @@ -0,0 +1,34 @@ +'use client'; + +import styles from './CalendarDayButton.module.css'; + +export type CalendarDayButtonProps = { + date: Date; + selected?: boolean; + onSelect?: (date: Date) => void; + className?: string; +}; + +const WEEKDAY_KO = ['일', '월', '화', '수', '목', '금', '토'] as const; + +export default function CalendarDayButton({ + date, + selected, + onSelect, + className, +}: CalendarDayButtonProps) { + const weekday = WEEKDAY_KO[date.getDay()]; + const day = date.getDate(); + + return ( + + ); +} diff --git a/src/components/calendar/CalendarButton/WeekDateBar.module.css b/src/components/calendar/CalendarButton/WeekDateBar.module.css new file mode 100644 index 0000000..87f0bf2 --- /dev/null +++ b/src/components/calendar/CalendarButton/WeekDateBar.module.css @@ -0,0 +1,29 @@ +.container { + width: 100%; + height: 68px; + + display: flex; + align-items: center; + justify-content: space-between; + + /* ✅ 버튼들이 자연스럽게 벌어지도록 */ + gap: 8px; + + box-sizing: border-box; +} + +/* Tablet 이하에서 너무 길어지면 가운데 레이아웃 맞추려고 제한만 살짝 */ +@media (max-width: 1279px) { + .container { + height: 68px; + gap: 8px; + } +} + +/* ✅ Mobile(<=767): 340 고정 금지. 100%로 늘어나게 */ +@media (max-width: 767px) { + .container { + height: 49px; + gap: 6px; + } +} diff --git a/src/components/calendar/CalendarButton/WeekDateBar.tsx b/src/components/calendar/CalendarButton/WeekDateBar.tsx new file mode 100644 index 0000000..b40c79a --- /dev/null +++ b/src/components/calendar/CalendarButton/WeekDateBar.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import styles from './WeekDateBar.module.css'; +import CalendarDayButton from './CalendarDayButton'; + +export type WeekDateBarProps = { + /** 선택된 날짜(컨트롤드) */ + value?: Date; + /** 선택된 날짜(언컨트롤드) */ + defaultValue?: Date; + /** 날짜 선택 */ + onChange?: (date: Date) => void; + + /** “현재 보고 있는 주” 기준 날짜(화살표로 주 이동할 때 이 값만 바꾸면 됨) */ + viewDate?: Date; + + className?: string; +}; + +function addDays(d: Date, days: number) { + const next = new Date(d); + next.setDate(d.getDate() + days); + return next; +} + +/** 월요일 시작 */ +function startOfWeekMonday(d: Date) { + const day = d.getDay(); // 0=일 + const diff = (day + 6) % 7; // 월=0 ... 일=6 + const start = new Date(d); + start.setHours(0, 0, 0, 0); + start.setDate(d.getDate() - diff); + return start; +} + +function isSameDay(a: Date, b: Date) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +export default function WeekDateBar({ + value, + defaultValue, + onChange, + viewDate, + className, +}: WeekDateBarProps) { + const isControlled = value instanceof Date; + const [internalValue, setInternalValue] = useState(defaultValue ?? new Date()); + + const selectedDate = isControlled ? (value as Date) : internalValue; + + // ✅ viewDate가 있으면 그 주를 보여주고, 없으면 선택된 날짜 기준 주 + const anchor = viewDate ?? selectedDate; + + const weekDates = useMemo(() => { + const start = startOfWeekMonday(anchor); + return Array.from({ length: 7 }, (_, i) => addDays(start, i)); + }, [anchor]); + + const handleSelect = (d: Date) => { + if (!isControlled) setInternalValue(d); + onChange?.(d); + }; + + return ( +
+ {weekDates.map((d) => ( + + ))} +
+ ); +}