diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c8f649 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +API_BASE_URL=https://fe-project-cowokers.vercel.app +API_TEAM_ID=20-1 +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore index 58ceba1..31ab68d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# MCP 설정 (API 토큰 포함) +.mcp.json + # dependencies /node_modules /.pnp @@ -41,7 +44,78 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts - -# personal doc -.documents/* +# dev scripts +scripts/refresh-dev-token.mjs +.dev-session.json +.documents/AI_RULES.md +.documents/clean.md .documents/daily-report.md +.documents/demands.md +.documents/rules/_sections.md +.documents/rules/_template.md +.documents/rules/advanced-event-handler-refs.md +.documents/rules/advanced-init-once.md +.documents/rules/advanced-use-latest.md +.documents/rules/async-api-routes.md +.documents/rules/async-defer-await.md +.documents/rules/async-dependencies.md +.documents/rules/async-parallel.md +.documents/rules/async-suspense-boundaries.md +.documents/rules/bundle-barrel-imports.md +.documents/rules/bundle-conditional.md +.documents/rules/bundle-defer-third-party.md +.documents/rules/bundle-dynamic-imports.md +.documents/rules/bundle-preload.md +.documents/rules/client-event-listeners.md +.documents/rules/client-localstorage-schema.md +.documents/rules/client-passive-event-listeners.md +.documents/rules/client-swr-dedup.md +.documents/rules/js-batch-dom-css.md +.documents/rules/js-cache-function-results.md +.documents/rules/js-cache-property-access.md +.documents/rules/js-cache-storage.md +.documents/rules/js-combine-iterations.md +.documents/rules/js-early-exit.md +.documents/rules/js-hoist-regexp.md +.documents/rules/js-index-maps.md +.documents/rules/js-length-check-first.md +.documents/rules/js-min-max-loop.md +.documents/rules/js-set-map-lookups.md +.documents/rules/js-tosorted-immutable.md +.documents/rules/rendering-activity.md +.documents/rules/rendering-animate-svg-wrapper.md +.documents/rules/rendering-conditional-render.md +.documents/rules/rendering-content-visibility.md +.documents/rules/rendering-hoist-jsx.md +.documents/rules/rendering-hydration-no-flicker.md +.documents/rules/rendering-hydration-suppress-warning.md +.documents/rules/rendering-svg-precision.md +.documents/rules/rendering-usetransition-loading.md +.documents/rules/rerender-defer-reads.md +.documents/rules/rerender-dependencies.md +.documents/rules/rerender-derived-state-no-effect.md +.documents/rules/rerender-derived-state.md +.documents/rules/rerender-functional-setstate.md +.documents/rules/rerender-lazy-state-init.md +.documents/rules/rerender-memo-with-default-value.md +.documents/rules/rerender-memo.md +.documents/rules/rerender-move-effect-to-event.md +.documents/rules/rerender-simple-expression-in-memo.md +.documents/rules/rerender-transitions.md +.documents/rules/rerender-use-ref-transient-values.md +.documents/rules/server-after-nonblocking.md +.documents/rules/server-auth-actions.md +.documents/rules/server-cache-lru.md +.documents/rules/server-cache-react.md +.documents/rules/server-dedup-props.md +.documents/rules/server-parallel-fetching.md +.documents/rules/server-serialization.md +.gitignore +.claude/CLAUDE.md +.claude/config.json +.husky/pre-push +.husky/pre-push +.husky/pre-push +coworkers-swagger.json +.gitignore +GEMINI.md diff --git a/src/app/(root)/mypage/hooks/useUser.ts b/src/app/(root)/mypage/hooks/useUser.ts index 590fbd3..0f87c94 100644 --- a/src/app/(root)/mypage/hooks/useUser.ts +++ b/src/app/(root)/mypage/hooks/useUser.ts @@ -3,8 +3,14 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { getUser, updateUser, deleteUser, changePassword, uploadImage } from '@/shared/apis/user'; -import type { UserResponse } from '@/shared/apis/user'; +import { + getUser, + updateUser, + deleteUser, + changePassword, + uploadImage, +} from '@/shared/apis/user/index'; +import type { UserResponse } from '@/shared/apis/user/index'; export function useUser() { const router = useRouter(); diff --git a/src/app/[teamid]/_domain/apis/group.ts b/src/app/[teamid]/_domain/apis/group.ts new file mode 100644 index 0000000..ee37940 --- /dev/null +++ b/src/app/[teamid]/_domain/apis/group.ts @@ -0,0 +1,37 @@ +import { requestJson, requestVoid } from '@/shared/apis/groups/http'; +import type { Group, Task, UpdateGroupBody } from './types'; + +const GROUP_ERROR_MESSAGE = { + fetch: '그룹 정보 조회 실패', + update: '그룹 정보 수정 실패', + delete: '그룹 삭제 실패', + invitation: '초대 링크 조회 실패', + tasks: '그룹 작업 조회 실패', +} as const; + +export function getGroup(groupId: number): Promise { + return requestJson(`/groups/${groupId}`, GROUP_ERROR_MESSAGE.fetch); +} + +export function updateGroup(groupId: number, body: UpdateGroupBody): Promise { + return requestJson(`/groups/${groupId}`, GROUP_ERROR_MESSAGE.update, { + method: 'PATCH', + body: JSON.stringify(body), + }); +} + +export function deleteGroup(groupId: number): Promise { + return requestVoid(`/groups/${groupId}`, GROUP_ERROR_MESSAGE.delete, { + method: 'DELETE', + }); +} + +// API 응답: 초대 토큰 문자열 (JWT) 그대로 반환 +export function getGroupInvitation(groupId: number): Promise { + return requestJson(`/groups/${groupId}/invitation`, GROUP_ERROR_MESSAGE.invitation); +} + +export function getGroupTasks(groupId: number, date?: string): Promise { + const query = date ? `?date=${encodeURIComponent(date)}` : ''; + return requestJson(`/groups/${groupId}/tasks${query}`, GROUP_ERROR_MESSAGE.tasks); +} diff --git a/src/app/[teamid]/_domain/apis/taskList.ts b/src/app/[teamid]/_domain/apis/taskList.ts new file mode 100644 index 0000000..d1149a6 --- /dev/null +++ b/src/app/[teamid]/_domain/apis/taskList.ts @@ -0,0 +1,31 @@ +import { requestJson, requestVoid } from '@/shared/apis/groups/http'; +import type { TaskList } from './types'; + +const TASK_LIST_ERROR_MESSAGE = { + create: '작업 목록 생성 실패', + fetch: '작업 목록 조회 실패', + delete: '작업 목록 삭제 실패', +} as const; + +export function createTaskList(groupId: number, name: string): Promise { + return requestJson(`/groups/${groupId}/task-lists`, TASK_LIST_ERROR_MESSAGE.create, { + method: 'POST', + body: JSON.stringify({ name }), + }); +} + +export function getTaskList(groupId: number, taskListId: number, date?: string): Promise { + const query = date ? `?date=${encodeURIComponent(date)}` : ''; + return requestJson( + `/groups/${groupId}/task-lists/${taskListId}${query}`, + TASK_LIST_ERROR_MESSAGE.fetch, + ); +} + +export function deleteTaskList(groupId: number, taskListId: number): Promise { + return requestVoid( + `/groups/${groupId}/task-lists/${taskListId}`, + TASK_LIST_ERROR_MESSAGE.delete, + { method: 'DELETE' }, + ); +} diff --git a/src/app/[teamid]/_domain/apis/types.ts b/src/app/[teamid]/_domain/apis/types.ts new file mode 100644 index 0000000..454f016 --- /dev/null +++ b/src/app/[teamid]/_domain/apis/types.ts @@ -0,0 +1,60 @@ +export type MemberRole = 'ADMIN' | 'MEMBER'; + +export type FrequencyType = 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY'; + +export interface GroupMember { + userId: number; + groupId: number; + userName: string; + userEmail: string; + userImage: string; + role: MemberRole; +} + +export interface TaskUser { + id: number; + nickname: string; + image: string | null; +} + +export interface Task { + id: number; + name: string; + description: string | null; + date: string; + doneAt: string | null; + updatedAt: string; + frequency: FrequencyType; + recurringId: number; + deletedAt: string | null; + commentCount: number; + displayIndex: number; + writer: TaskUser | null; + doneBy: { user: TaskUser | null } | null; +} + +export interface TaskList { + id: number; + name: string; + groupId: number; + displayIndex: number; + createdAt: string; + updatedAt: string; + tasks: Task[]; +} + +export interface Group { + id: number; + teamId: string; + name: string; + image: string; + createdAt: string; + updatedAt: string; + members: GroupMember[]; + taskLists: Omit[]; +} + +export interface UpdateGroupBody { + name?: string; + image?: string | null; +} diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.module.css b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.module.css new file mode 100644 index 0000000..41c3a20 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.module.css @@ -0,0 +1,85 @@ +.wrapper { + display: flex; + flex-direction: column; + gap: 20px; +} + +.boardHeader { + display: flex; + align-items: center; + gap: 8px; +} + +.boardTitle { + font-size: 16px; + font-weight: 600; + color: var(--color-text-primary); + margin: 0; +} + +.boardCount { + color: var(--color-text-default); + font-weight: 400; +} + +.board { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + align-items: start; +} + +/* 태블릿: 컬럼을 한 줄씩 세로로 쌓기 */ +@media (min-width: 768px) and (max-width: 1279px) { + .board { + display: flex; + flex-direction: column; + gap: 16px; + } +} + +/* 모바일 */ +@media (max-width: 767px) { + .board { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + max-width: 100%; + box-sizing: border-box; + overflow-x: hidden; + } +} + +.addListButton { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 1140px; + padding: 14px 24px; + background: var(--color-background-primary); + border: 1px dashed var(--color-background-tertiary); + border-radius: 12px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: var(--color-text-default); + font-family: inherit; + transition: + background 0.15s, + color 0.15s; +} + +.addListButton:hover { + background: var(--color-brand-secondary); + color: var(--color-brand-primary); + border-color: var(--color-brand-primary); +} + +.dragOverlay { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + border-radius: 12px; + opacity: 0.92; + cursor: grabbing; +} diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx new file mode 100644 index 0000000..f566702 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { DndContext, DragOverlay } from '@dnd-kit/core'; +import TodoCard from '@/components/todo-card/TodoCard'; +import AddTodoList from '@/components/Modal/domain/components/AddTodoList/AddTodoList'; +import KanbanColumn from './KanbanColumn'; +import styles from './KanbanBoard.module.css'; +import type { KanbanStatus } from '../../interfaces/team'; +import type { TaskList } from '../../apis/types'; +import { useKanbanTasks } from '../../hooks/useKanbanTasks'; +import { useKanbanDnd } from '../../hooks/useKanbanDnd'; + +export const KANBAN_COLUMNS: { id: KanbanStatus; label: string }[] = [ + { id: 'todo', label: '할 일' }, + { id: 'inProgress', label: '진행중' }, + { id: 'done', label: '완료' }, +]; + +const COLUMN_IDS = KANBAN_COLUMNS.map((c) => c.id); + +interface KanbanBoardProps { + groupId: number; + teamId: string; + taskLists: Omit[]; +} + +export default function KanbanBoard({ groupId, teamId, taskLists }: KanbanBoardProps) { + const { + tasks, + setTasks, + addingStatus, + newListTitle, + handleItemCheckedChange, + handleCardClick, + handleDeleteTask, + handleUpdateTask, + handleAddTask, + handleAddListSubmit, + handleAddListClose, + handleNewListTitleChange, + } = useKanbanTasks(groupId, teamId, taskLists); + + const { activeTask, sensors, handleDragStart, handleDragEnd } = useKanbanDnd( + tasks, + setTasks, + COLUMN_IDS, + ); + + return ( +
+
+

+ 할 일 목록 ({tasks.length}개) +

+
+ + +
+ {KANBAN_COLUMNS.map((col) => ( + t.status === col.id)} + onItemCheckedChange={handleItemCheckedChange} + onCardClick={handleCardClick} + onAddTask={handleAddTask} + onDeleteTask={handleDeleteTask} + onUpdateTask={handleUpdateTask} + /> + ))} +
+ + + {activeTask && ( +
+ +
+ )} +
+
+ + void handleAddListSubmit()} + input={{ + props: { + value: newListTitle, + onChange: (e) => handleNewListTitleChange(e.target.value), + }, + }} + /> +
+ ); +} diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css new file mode 100644 index 0000000..ae1a2bc --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css @@ -0,0 +1,47 @@ +.column { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.columnHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px 0 24px; + background-color: var(--color-background-tertiary); + border-radius: 12px; + height: 38px; +} + +.columnTitle { + font-size: 16px; + font-weight: 600; + color: var(--color-text-primary); +} + +.addButton { + display: flex; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: none; + cursor: pointer; + flex-shrink: 0; +} + +.itemList { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 40px; + border-radius: 12px; + padding: 4px; + transition: background 0.15s; +} + +.isOver { + background: var(--color-brand-secondary); +} diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx new file mode 100644 index 0000000..3304aa7 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { memo } from 'react'; +import { useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; + +import KanbanItem from './KanbanItem'; +import styles from './KanbanColumn.module.css'; +import type { KanbanTask, KanbanStatus, TaskItem } from '../../interfaces/team'; +import Image from 'next/image'; +import Plus from '@/assets/buttons/plus/plusBoxButton.svg'; + +interface KanbanColumnProps { + status: KanbanStatus; + label: string; + tasks: KanbanTask[]; + onItemCheckedChange?: (taskId: string, itemId: string, checked: boolean) => void; + onCardClick?: (taskId: string) => void; + onAddTask?: (status: KanbanStatus) => void; + onDeleteTask?: (taskId: string) => void; + onEditTask?: (taskId: string) => void; + onUpdateTask?: (taskId: string, updatedData: { title: string; items: TaskItem[] }) => void; +} + +function KanbanColumn({ + status, + label, + tasks, + onItemCheckedChange, + onCardClick, + onAddTask, + onDeleteTask, + onEditTask, + onUpdateTask, +}: KanbanColumnProps) { + const { setNodeRef, isOver } = useDroppable({ id: status }); + const itemIds = tasks.map((t) => t.id); + + return ( +
+
+

{label}

+ +
+ + +
+ {tasks.map((task) => ( + + ))} +
+
+
+ ); +} + +export default memo(KanbanColumn); diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css new file mode 100644 index 0000000..85900e9 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css @@ -0,0 +1,144 @@ +.item { + position: relative; + display: flex; + flex-direction: column; + cursor: pointer; + width: 100%; +} + +.cardWrapper { + position: relative; +} + +.todoCard { + flex: 1; + /* 접힘/펼침 전환 시 타이틀 수평 시프팅 방지: folded 상태와 패딩·보더 두께 통일 */ + padding-left: 20px !important; + border: 1px solid transparent !important; +} + +/* 피그마 fold=True 상태: 54px 높이, 좌측 패딩 20px, 테두리만 표시 */ +.todoCardFolded { + height: 54px !important; + padding: 0 12px 0 20px !important; + justify-content: center !important; + gap: 0 !important; + box-shadow: none !important; + border: 1px solid #e2e8f0 !important; +} + +.contextMenu { + position: absolute; + top: 40px; + right: 8px; + z-index: 10; + display: flex; + flex-direction: column; + background-color: var(--color-background-inverse); + border-radius: 8px; + box-shadow: + 0 4px 6px rgba(0, 0, 0, 0.1), + 0 2px 4px rgba(0, 0, 0, 0.06); + overflow: hidden; + min-width: 120px; +} + +.menuItem { + width: 100%; + padding: 12px 16px; + text-align: left; + font-size: 14px; + font-weight: 400; + color: var(--color-text-primary); + background: none; + border: none; + cursor: pointer; +} + +.menuItem:hover { + background-color: var(--color-background-tertiary); +} + +.editCard { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + background-color: var(--color-background-inverse); + border-radius: 8px; + border: 1px solid var(--color-brand-primary); +} + +.editTitleInput { + width: 100%; + padding: 6px 8px; + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); + background-color: var(--color-background-primary); + border: 1px solid var(--color-border-primary); + border-radius: 6px; + outline: none; + box-sizing: border-box; +} + +.editTitleInput:focus { + border-color: var(--color-brand-primary); +} + +.editItems { + display: flex; + flex-direction: column; + gap: 4px; +} + +.editItemInput { + width: 100%; + padding: 5px 8px; + font-size: 13px; + color: var(--color-text-primary); + background-color: var(--color-background-primary); + border: 1px solid var(--color-border-primary); + border-radius: 6px; + outline: none; + box-sizing: border-box; +} + +.editItemInput:focus { + border-color: var(--color-brand-primary); +} + +.editActions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.cancelButton { + padding: 6px 12px; + font-size: 13px; + color: var(--color-text-secondary); + background: none; + border: 1px solid var(--color-border-primary); + border-radius: 6px; + cursor: pointer; +} + +.cancelButton:hover { + background-color: var(--color-background-tertiary); +} + +.saveButton { + padding: 6px 12px; + font-size: 13px; + color: var(--color-text-inverse); + background-color: var(--color-brand-primary); + border: none; + border-radius: 6px; + cursor: pointer; +} + +.saveButton:hover { + opacity: 0.85; +} diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx new file mode 100644 index 0000000..123939e --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { memo, useState, useRef, useEffect } from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import TodoCard from '@/components/todo-card/TodoCard'; +import styles from './KanbanItem.module.css'; +import type { KanbanTask, TaskItem } from '../../interfaces/team'; + +interface KanbanItemProps { + task: KanbanTask; + onItemCheckedChange?: (taskId: string, itemId: string, checked: boolean) => void; + onCardClick?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; + onEditTask?: (taskId: string) => void; + onUpdateTask?: (taskId: string, updatedData: { title: string; items: TaskItem[] }) => void; +} + +function KanbanItem({ + task, + onItemCheckedChange, + onCardClick, + onDeleteTask, + onEditTask, + onUpdateTask, +}: KanbanItemProps) { + // 할일이 없거나 일부만 완료된 경우 펼침, 모두 완료된 경우 접힘 + const allChecked = task.items.length > 0 && task.items.every((item) => item.checked); + const [isExpanded, setIsExpanded] = useState(!allChecked); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editTitle, setEditTitle] = useState(task.title); + const [editItems, setEditItems] = useState(task.items); + const containerRef = useRef(null); + + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: task.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + }; + + // 메뉴 외부 클릭 시 닫기 + useEffect(() => { + if (!isMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isMenuOpen]); + + const handleContainerClick = (e: React.MouseEvent) => { + if (isEditing) return; + const target = e.target as HTMLElement; + if (!target.closest('button, input, label, a')) { + setIsExpanded((prev) => !prev); + } + }; + + const handleKebabClick = () => { + setIsMenuOpen((prev) => !prev); + }; + + const handleEdit = () => { + setIsMenuOpen(false); + setEditTitle(task.title); + setEditItems(task.items); + setIsEditing(true); + onEditTask?.(task.id); + }; + + const handleDelete = () => { + setIsMenuOpen(false); + onDeleteTask?.(task.id); + }; + + const handleSave = () => { + onUpdateTask?.(task.id, { title: editTitle, items: editItems }); + setIsEditing(false); + }; + + const handleCancel = () => { + setIsEditing(false); + }; + + const handleItemTextChange = (itemId: string, newText: string) => { + setEditItems((prev) => + prev.map((item) => (item.id === itemId ? { ...item, text: newText } : item)), + ); + }; + + return ( + // dnd-kit의 PointerSensor는 button 요소를 드래그 제외 대상으로 처리하므로 + // listeners를 item 컨테이너에 배치하여 카드 전체 영역에서 드래그 가능하게 함 +
+
+ {isEditing ? ( +
+ setEditTitle(e.target.value)} + placeholder="할 일 제목" + /> + {editItems.length > 0 && ( +
+ {editItems.map((item) => ( + handleItemTextChange(item.id, e.target.value)} + placeholder="항목 내용" + /> + ))} +
+ )} +
+ + +
+
+ ) : ( + <> + onItemCheckedChange(task.id, itemId, checked) + : undefined + } + onKebabClick={handleKebabClick} + className={isExpanded ? styles.todoCard : styles.todoCardFolded} + /> + {isMenuOpen && ( +
+ + +
+ )} + + )} +
+
+ ); +} + +export default memo(KanbanItem); diff --git a/src/app/[teamid]/_domain/components/Member/MemberCard.module.css b/src/app/[teamid]/_domain/components/Member/MemberCard.module.css new file mode 100644 index 0000000..1d72e46 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Member/MemberCard.module.css @@ -0,0 +1,66 @@ +.card { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid var(--color-background-tertiary); +} + +.card:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.kebab { + margin-left: auto; + flex-shrink: 0; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-background-tertiary); + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.avatarImg { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatarFallback { + font-size: 14px; + font-weight: 600; + color: var(--color-text-default); +} + +.info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.name { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.email { + font-size: 12px; + font-weight: 400; + color: var(--color-text-default); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/app/[teamid]/_domain/components/Member/MemberCard.tsx b/src/app/[teamid]/_domain/components/Member/MemberCard.tsx new file mode 100644 index 0000000..f5b0cdf --- /dev/null +++ b/src/app/[teamid]/_domain/components/Member/MemberCard.tsx @@ -0,0 +1,46 @@ +import Image from 'next/image'; +import MemberKebabMenu from './MemberKebabMenu'; +import styles from './MemberCard.module.css'; +import type { GroupMember } from '../../apis/types'; +import { useRemoveMemberMutation } from '../../queries/useRemoveMemberMutation'; + +interface MemberCardProps { + member: GroupMember; + isAdmin: boolean; + groupId: number; +} + +export default function MemberCard({ member, isAdmin, groupId }: MemberCardProps) { + const removeMemberMutation = useRemoveMemberMutation(groupId); + + const handleDelete = () => { + removeMemberMutation.mutate(member.userId); + }; + + return ( +
+ +
+ {member.userName} + {member.userEmail} +
+ {isAdmin && ( +
+ +
+ )} +
+ ); +} diff --git a/src/app/[teamid]/_domain/components/Member/MemberKebabMenu.module.css b/src/app/[teamid]/_domain/components/Member/MemberKebabMenu.module.css new file mode 100644 index 0000000..c019ef0 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Member/MemberKebabMenu.module.css @@ -0,0 +1,63 @@ +.container { + position: relative; + display: inline-block; +} + +.trigger { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: none; + border: none; + cursor: pointer; + color: var(--color-icon-primary); + transition: color 0.2s; +} + +.trigger:hover { + color: var(--color-text-secondary); +} + +.icon { + font-size: 20px; + line-height: 1; + user-select: none; +} + +.dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 120px; + background: var(--color-background-primary); + border: 1px solid var(--color-border-primary); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + overflow: hidden; + z-index: 100; +} + +.menuItem { + display: block; + width: 100%; + padding: 12px 16px; + text-align: left; + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); + background: none; + border: none; + cursor: pointer; + transition: background-color 0.2s; +} + +.menuItem:hover { + background-color: var(--color-background-secondary); +} + +.menuItem:active { + background-color: var(--color-background-tertiary); +} diff --git a/src/app/[teamid]/_domain/components/Member/MemberKebabMenu.tsx b/src/app/[teamid]/_domain/components/Member/MemberKebabMenu.tsx new file mode 100644 index 0000000..8e6000d --- /dev/null +++ b/src/app/[teamid]/_domain/components/Member/MemberKebabMenu.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import styles from './MemberKebabMenu.module.css'; + +interface MemberKebabMenuProps { + onDelete: () => void; +} + +export default function MemberKebabMenu({ onDelete }: MemberKebabMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + const handleDelete = () => { + onDelete(); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( +
+ +
+ )} +
+ ); +} diff --git a/src/app/[teamid]/_domain/components/Member/MemberSection.module.css b/src/app/[teamid]/_domain/components/Member/MemberSection.module.css new file mode 100644 index 0000000..e274f08 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Member/MemberSection.module.css @@ -0,0 +1,60 @@ +.section { + background: var(--color-background-primary); + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 44px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.title { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + margin: 0; +} + +.count { + color: var(--color-text-default); + font-weight: 400; +} + +.inviteButton { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 600; + color: var(--color-brand-primary); + padding: 0; + font-family: inherit; + white-space: nowrap; +} + +.inviteButton:hover { + color: var(--color-interaction-hover); +} + +.list { + display: flex; + flex-direction: column; +} + +/* Toast 컴포넌트를 화면 상단 중앙에 위치시키기 위한 오버라이드 */ +.toastCenter { + left: calc(50% - 434px) !important; +} + +@media (max-width: 768px) { + .toastCenter { + left: calc(50% - 172px) !important; + } +} diff --git a/src/app/[teamid]/_domain/components/Member/MemberSection.tsx b/src/app/[teamid]/_domain/components/Member/MemberSection.tsx new file mode 100644 index 0000000..9554c09 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Member/MemberSection.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import MemberInvite from '@/components/Modal/domain/components/MemberInvite/MemberInvite'; +import Toast from '@/components/toast/Toast'; +import MemberCard from './MemberCard'; +import styles from './MemberSection.module.css'; +import type { GroupMember } from '../../apis/types'; +import { useGroupInvitationQuery } from '../../queries/useGroupInvitationQuery'; + +const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; + +function buildInviteLink(inviteToken: string): string { + // 프론트엔드 도메인에서 초대 수락 경로로 이동하는 링크 생성 + if (typeof window !== 'undefined') { + return `${window.location.origin}/addteam/join?token=${inviteToken}`; + } + return `${BASE_URL}/addteam/join?token=${inviteToken}`; +} + +interface MemberSectionProps { + members: GroupMember[]; + isAdmin: boolean; + groupId: number; + teamId: string; +} + +export default function MemberSection({ members, isAdmin, groupId }: MemberSectionProps) { + const [isInviteOpen, setIsInviteOpen] = useState(false); + const [isToastOpen, setIsToastOpen] = useState(false); + + const { data: invitationData } = useGroupInvitationQuery(groupId, isInviteOpen); + + const inviteLink = invitationData ? buildInviteLink(invitationData) : ''; + + const handleCopyLink = useCallback((link: string) => { + navigator.clipboard.writeText(link).catch(() => {}); + setIsToastOpen(true); + }, []); + + return ( +
+
+

+ 멤버 ({members.length}명) +

+ +
+ +
+ {members.map((member) => ( + + ))} +
+ + setIsInviteOpen(false)} + invite={{ + link: inviteLink, + onCopyLink: handleCopyLink, + }} + /> + + setIsToastOpen(false)} + actionLabel={null} + className={styles.toastCenter} + /> +
+ ); +} diff --git a/src/app/[teamid]/_domain/components/Team/SidebarWrapper.tsx b/src/app/[teamid]/_domain/components/Team/SidebarWrapper.tsx new file mode 100644 index 0000000..36098c6 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/SidebarWrapper.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { Sidebar } from '@/components/sidebar'; +import ProfileImage from '@/components/profile-img/ProfileImage'; +import { useCurrentUserQuery } from '@/shared/queries/user/useCurrentUserQuery'; +import TeamSidebarDropdown from './TeamSidebarDropdown'; + +export default function SidebarWrapper() { + const { data: currentUser } = useCurrentUserQuery(); + + return ( + } + isLoggedIn={!!currentUser} + profileImage={ + + } + profileName={currentUser?.nickname} + profileTeam={currentUser?.email} + /> + ); +} diff --git a/src/app/[teamid]/_domain/components/Team/TeamDashboard.module.css b/src/app/[teamid]/_domain/components/Team/TeamDashboard.module.css new file mode 100644 index 0000000..07e4b36 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamDashboard.module.css @@ -0,0 +1,52 @@ +.container { + max-width: 1550px; + width: 100%; + display: flex; + flex-direction: column; + padding: 24px; + gap: 24px; + min-height: 100%; +} + +.content { + display: flex; + gap: 24px; + align-items: flex-start; +} + +.divider { + width: 100%; + height: 1px; + background: var(--Border-Primary, #e2e8f0); +} + +.kanbanArea { + flex: 1; + min-width: 0; +} + +.rightPanel { + width: 300px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +@media (max-width: 1280px) { + .content { + flex-direction: column; + align-items: stretch; /* column 방향에서 자식이 전체 너비를 채우도록 */ + } + + .rightPanel { + width: 100%; + } +} + +@media (max-width: 767px) { + .container { + padding: 16px; + gap: 16px; + } +} diff --git a/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx b/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx new file mode 100644 index 0000000..c66980f --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useQuery, useQueries } from '@tanstack/react-query'; +import KanbanBoard from '../Kanban/KanbanBoard'; +import TodayReport from '../TodayReport/TodayReport'; +import MemberSection from '../Member/MemberSection'; +import WarningModal from '@/components/Modal/domain/components/WarningModal/WarningModal'; +import styles from './TeamDashboard.module.css'; +import { groupQueryOptions } from '../../queries/useGroupQuery'; +import { taskListQueryOptions } from '../../queries/useTaskListQuery'; +import { useCurrentUserQuery } from '@/shared/queries/user/useCurrentUserQuery'; +import { useDeleteGroupMutation } from '../../queries/useDeleteGroupMutation'; +import type { GroupMember } from '../../apis/types'; + +function getTodayDateString(): string { + return new Date().toISOString().split('T')[0]; +} + +function isGroupAdmin(members: GroupMember[] | undefined, userId: number): boolean { + return members?.some((m) => m.userId === userId && m.role === 'ADMIN') ?? false; +} + +function isTaskListDone(tasks: { doneAt: string | null }[]): boolean { + return tasks.length > 0 && tasks.every((t) => t.doneAt !== null); +} + +export default function TeamDashboard() { + const params = useParams<{ teamid: string }>(); + const router = useRouter(); + const teamid = params?.teamid ?? ''; + const groupId = Number(teamid); + + const { + data: group, + isPending, + isError, + } = useQuery({ + ...groupQueryOptions(groupId), + enabled: groupId > 0, + }); + const today = getTodayDateString(); + + const taskListQueries = useQueries({ + queries: (group?.taskLists ?? []).map((tl) => taskListQueryOptions(groupId, tl.id, today)), + }); + + const { data: currentUser } = useCurrentUserQuery(); + const { mutate: deleteGroup } = useDeleteGroupMutation(groupId); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isNavigating, setIsNavigating] = useState(false); + + if (isNaN(groupId) || groupId <= 0) { + return ( +
+

유효하지 않은 팀 ID입니다.

+
+ ); + } + + if (isPending) { + return
로딩 중...
; + } + + if (isError || !group) { + if (isNavigating) return
; + return ( +
+

그룹을 찾을 수 없습니다.

+
+ ); + } + + const totalTasks = taskListQueries.length; + const doneTasks = taskListQueries.filter((q) => isTaskListDone(q.data?.tasks ?? [])).length; + const isAdmin = currentUser ? isGroupAdmin(group.members, currentUser.id) : false; + + const handleConfirmDelete = () => { + // onSettled가 currentUser 쿼리를 invalidate하기 전에 미리 계산 + const remaining = (currentUser?.memberships ?? []) + .filter((m) => m.group.id !== groupId) + .map((m) => m.group); + + setIsDeleteModalOpen(false); + setIsNavigating(true); + + deleteGroup(undefined, { + onSuccess: () => { + router.push(remaining.length > 0 ? `/${remaining[0].id}` : '/addteam'); + }, + onError: () => { + setIsNavigating(false); + }, + }); + }; + + return ( +
+ setIsDeleteModalOpen(true) : undefined} + /> + + setIsDeleteModalOpen(false)} + onConfirm={handleConfirmDelete} + text={{ + title: '팀을 삭제하시겠어요?', + description: '팀을 삭제하면 모든 데이터가\n영구적으로 삭제됩니다.', + closeLabel: '취소', + confirmLabel: '삭제하기', + }} + /> + +
+ +
+
+ +
+ + +
+
+ ); +} diff --git a/src/app/[teamid]/_domain/components/Team/TeamNavClient.module.css b/src/app/[teamid]/_domain/components/Team/TeamNavClient.module.css new file mode 100644 index 0000000..9255828 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamNavClient.module.css @@ -0,0 +1,21 @@ +.tabletWrapper { + display: none; +} + +.mobileWrapper { + display: none; +} + +/* 태블릿 */ +@media (min-width: 768px) and (max-width: 1279px) { + .tabletWrapper { + display: block; + } +} + +/* 모바일 */ +@media (max-width: 767px) { + .mobileWrapper { + display: block; + } +} diff --git a/src/app/[teamid]/_domain/components/Team/TeamNavClient.tsx b/src/app/[teamid]/_domain/components/Team/TeamNavClient.tsx new file mode 100644 index 0000000..6ed8eb0 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamNavClient.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useState } from 'react'; + +import { MobileHeader, MobileDrawer } from '@/components/sidebar'; +import ProfileImage from '@/components/profile-img/ProfileImage'; +import { useCurrentUserQuery } from '@/shared/queries/user/useCurrentUserQuery'; +import TeamTabletHeader from './TeamTabletHeader'; +import TeamSidebarDropdown from './TeamSidebarDropdown'; +import styles from './TeamNavClient.module.css'; + +export default function TeamNavClient() { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const { data: currentUser } = useCurrentUserQuery(); + + const openDrawer = () => setIsDrawerOpen(true); + const closeDrawer = () => setIsDrawerOpen(false); + + return ( + <> + {/* 태블릿 헤더 */} +
+ +
+ + {/* 모바일 헤더 */} +
+ + } + onMenuClick={openDrawer} + /> +
+ + {/* 태블릿/모바일 공통 사이드바 드로어 */} + + + + + ); +} diff --git a/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.module.css b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.module.css new file mode 100644 index 0000000..0955fce --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.module.css @@ -0,0 +1,130 @@ +.container { + display: flex; + flex-direction: column; + width: 100%; + gap: 12px; +} + +/* 팀 선택 섹션 (트리거 + 팀 목록 + 추가 버튼) */ +.section { + display: flex; + flex-direction: column; + width: 100%; + gap: 8px; + padding-bottom: 12px; +} + +/* 팀 선택 헤더 트리거 */ +.trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 8px 16px; + border-radius: 12px; + border: none; + background: transparent; + cursor: pointer; +} + +.triggerLeft { + display: flex; + align-items: center; + gap: 12px; +} + +.triggerLabel { + font-family: Pretendard, sans-serif; + font-weight: 600; + font-size: 16px; + line-height: 1.1875; + color: var(--color-text-disabled); +} + +.arrowOpen { + transform: rotate(180deg); +} + +/* 개별 팀 항목 */ +.teamItem { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + border-radius: 12px; + text-decoration: none; + font-family: Pretendard, sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 1.1875; + color: var(--color-text-primary); + width: 100%; +} + +.teamItem:hover { + background: var(--color-brand-secondary); +} + +.active { + background: var(--color-brand-secondary); + color: var(--color-brand-primary); + font-weight: 600; +} + +.active img { + filter: brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(1500%) hue-rotate(208deg) + brightness(102%) contrast(97%); +} + +/* 팀 추가하기 버튼 */ +.addButton { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 100%; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--color-brand-primary); + background: transparent; + cursor: pointer; + font-family: Pretendard, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 1.2143; + color: var(--color-brand-primary); + text-decoration: none; +} + +.addIcon { + filter: brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(1500%) hue-rotate(208deg) + brightness(102%) contrast(97%); +} + +/* 구분선 */ +.divider { + border: none; + border-top: 1px solid var(--color-background-tertiary); + margin: 0; + width: 100%; +} + +/* 자유게시판 */ +.boardItem { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + border-radius: 12px; + text-decoration: none; + font-family: Pretendard, sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 1.1875; + color: var(--color-text-primary); + width: 100%; +} + +.boardItem:hover { + background: var(--color-brand-secondary); +} diff --git a/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx new file mode 100644 index 0000000..462b18c --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { useParams } from 'next/navigation'; + +import styles from './TeamSidebarDropdown.module.css'; +import chessSmall from '@/assets/icons/chess/chessSmall.svg'; +import downArrowSmall from '@/assets/icons/arrow/downArrowSmall.svg'; +import plusSmall from '@/assets/icons/plus/plusSMall.svg'; +import boardSmall from '@/assets/icons/board/boardSmall.svg'; +import { useCurrentUserQuery } from '@/shared/queries/user/useCurrentUserQuery'; + +type Props = { + isCollapsed?: boolean; +}; + +export default function TeamSidebarDropdown({ isCollapsed }: Props) { + const params = useParams<{ teamid: string }>(); + const teamid = params?.teamid ?? ''; + const [isOpen, setIsOpen] = useState(true); + + const { data: currentUser } = useCurrentUserQuery(); + const teams = currentUser?.memberships.map((m) => m.group) ?? []; + + if (isCollapsed) return null; + + return ( +
+ {/* 팀 선택 섹션 */} +
+ + + {isOpen && ( + <> + {teams.map((team) => ( + + + {team.name} + + ))} + + + 팀 + 추가하기 + + + )} +
+ + {/* 구분선 */} +
+ + {/* 자유게시판 */} + + + 자유게시판 + +
+ ); +} diff --git a/src/app/[teamid]/_domain/components/Team/TeamTabletHeader.module.css b/src/app/[teamid]/_domain/components/Team/TeamTabletHeader.module.css new file mode 100644 index 0000000..808d86d --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamTabletHeader.module.css @@ -0,0 +1,30 @@ +.tabletHeader { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: 60px; + padding: 0 16px; + background: var(--color-background-inverse); + border-bottom: 1px solid var(--color-background-tertiary); +} + +.hamburgerButton { + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.logo { + display: flex; + align-items: center; +} + +.profileArea { + display: flex; + align-items: center; +} diff --git a/src/app/[teamid]/_domain/components/Team/TeamTabletHeader.tsx b/src/app/[teamid]/_domain/components/Team/TeamTabletHeader.tsx new file mode 100644 index 0000000..03d540a --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamTabletHeader.tsx @@ -0,0 +1,36 @@ +'use client'; + +import Image from 'next/image'; + +import ProfileImage from '@/components/profile-img/ProfileImage'; +import { useCurrentUserQuery } from '@/shared/queries/user/useCurrentUserQuery'; +import logoLarge from '@/assets/logos/logoLarge.svg'; +import hamburgerIcon from '@/assets/buttons/hamburger/hamburger.svg'; +import styles from './TeamTabletHeader.module.css'; + +type Props = { + onMenuClick?: () => void; +}; + +export default function TeamTabletHeader({ onMenuClick }: Props) { + const { data: currentUser } = useCurrentUserQuery(); + + return ( +
+ +
+ COWORKERS +
+
+ +
+
+ ); +} diff --git a/src/app/[teamid]/_domain/components/TodayReport/TodayReport.module.css b/src/app/[teamid]/_domain/components/TodayReport/TodayReport.module.css new file mode 100644 index 0000000..c3bba23 --- /dev/null +++ b/src/app/[teamid]/_domain/components/TodayReport/TodayReport.module.css @@ -0,0 +1,150 @@ +.card { + width: 100%; + background: var(--color-background-primary); + border-radius: 20px; + box-shadow: 0px 15px 50px -12px rgba(0, 0, 0, 0.05); + padding: 32px 26px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 36px; +} + +.teamName { + font-size: 24px; + font-weight: 700; + color: var(--color-text-primary); + line-height: 1.167; + margin: 0; +} + +.content { + display: grid; + grid-template-columns: 1fr auto; + column-gap: 24px; + row-gap: 16px; +} + +.row { + grid-column: 1; + display: flex; + justify-content: space-between; + align-items: flex-end; +} + +.leftCol { + display: flex; + flex-direction: column; +} + +.progressLabel { + font-size: 14px; + font-weight: 500; + color: var(--color-interaction-inactive); + line-height: 1.214; +} + +.percent { + font-size: 40px; + font-weight: 700; + color: var(--color-brand-primary); + line-height: 1.193; +} + +.statsGroup { + display: flex; + align-items: center; + gap: 24px; +} + +.statItem { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.statLabel { + font-size: 12px; + font-weight: 500; + color: var(--color-interaction-inactive); +} + +.statValue { + font-size: 32px; + font-weight: 700; + color: var(--color-text-default); + line-height: 1.193; +} + +.statValueDone { + color: var(--color-brand-primary); +} + +.divider { + width: 1px; + align-self: stretch; + background: var(--color-background-tertiary); +} + +.progressRow { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + align-items: center; +} + +.progressBar { + min-width: 0; +} + +.settingsWrapper { + position: relative; + display: inline-flex; + flex-shrink: 0; +} + +.settingsButton { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +.dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 120px; + background: var(--color-background-primary); + border: 1px solid var(--color-border-primary); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + overflow: hidden; + z-index: 100; +} + +.dropdownItem { + display: block; + width: 100%; + padding: 12px 16px; + text-align: left; + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); + background: none; + border: none; + cursor: pointer; + transition: background-color 0.2s; +} + +.dropdownItem:hover { + background-color: var(--color-background-secondary); +} + +.dropdownItem:active { + background-color: var(--color-background-tertiary); +} diff --git a/src/app/[teamid]/_domain/components/TodayReport/TodayReport.tsx b/src/app/[teamid]/_domain/components/TodayReport/TodayReport.tsx new file mode 100644 index 0000000..acb09ef --- /dev/null +++ b/src/app/[teamid]/_domain/components/TodayReport/TodayReport.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import Image from 'next/image'; +import ProgressBar from '@/components/progressbar/ProgressBar'; +import SettingBig from '@/assets/icons/setting/SettingBig.svg'; +import styles from './TodayReport.module.css'; + +interface TodayReportProps { + teamName: string; + totalTasks: number; + doneTasks: number; + onDeleteGroup?: () => void; +} + +export default function TodayReport({ + teamName, + totalTasks, + doneTasks, + onDeleteGroup, +}: TodayReportProps) { + const progressPercent = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0; + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + if (!isDropdownOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsDropdownOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isDropdownOpen]); + + const handleDeleteGroup = () => { + setIsDropdownOpen(false); + onDeleteGroup?.(); + }; + + return ( +
+

{teamName}

+ +
+
+
+ 오늘의 진행 상황 + {progressPercent}% +
+ +
+
+ 오늘의 할 일 + {totalTasks} +
+ +
+ +
+ + {onDeleteGroup && ( +
+ + {isDropdownOpen && ( +
+ +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/app/[teamid]/_domain/constants/mockData.ts b/src/app/[teamid]/_domain/constants/mockData.ts new file mode 100644 index 0000000..33ca86d --- /dev/null +++ b/src/app/[teamid]/_domain/constants/mockData.ts @@ -0,0 +1,70 @@ +import type { TeamMember, KanbanTask, MockTeam } from '../interfaces/team'; + +export const MOCK_TEAM_NAME = '경영관리팀'; + +export const MOCK_TEAMS: MockTeam[] = [ + { id: '1', name: '경영관리팀' }, + { id: '2', name: '프로덕트팀' }, + { id: '3', name: '마케팅팀' }, + { id: '4', name: '콘텐츠팀' }, +]; + +export const MOCK_MEMBERS: TeamMember[] = [ + { id: '1', name: '우지은', email: 'jieun@codeit.com' }, + { id: '2', name: '김민준', email: 'minjun@codeit.com' }, + { id: '3', name: '이서연', email: 'seoyeon@codeit.com' }, + { id: '4', name: '박도현', email: 'dohyeon@codeit.com' }, +]; + +export const MOCK_TASKS: KanbanTask[] = [ + { + id: 'task-1', + title: '법인 설립', + status: 'todo', + items: [ + { id: 'item-1', text: '법인 설립 안내 드리기', checked: false }, + { id: 'item-2', text: '법인 설립 혹은 변경 등기 비용 안내 드리기', checked: false }, + { id: 'item-3', text: '입력해주신 정보를 바탕으로 등기신청서 제출하기', checked: true }, + ], + }, + { + id: 'task-2', + title: '마케팅 전략 수립', + status: 'todo', + items: [ + { id: 'item-4', text: '시장 조사 보고서 작성', checked: false }, + { id: 'item-5', text: 'SNS 마케팅 계획 수립', checked: false }, + ], + }, + { + id: 'task-3', + title: '제품 기획', + status: 'inProgress', + items: [ + { id: 'item-6', text: '사용자 인터뷰 진행', checked: true }, + { id: 'item-7', text: '기능 명세서 작성', checked: true }, + { id: 'item-8', text: '와이어프레임 작성', checked: false }, + { id: 'item-9', text: '프로토타입 제작', checked: false }, + { id: 'item-10', text: '사용성 테스트', checked: false }, + ], + }, + { + id: 'task-4', + title: '회의록 정리', + status: 'done', + items: [ + { id: 'item-11', text: '주간 회의 내용 정리', checked: true }, + { id: 'item-12', text: '액션 아이템 배분', checked: true }, + { id: 'item-13', text: '팀원들에게 공유', checked: true }, + { id: 'item-14', text: '다음 회의 일정 잡기', checked: true }, + { id: 'item-15', text: '회의록 보관', checked: true }, + ], + }, +]; + +export const MOCK_TODAY_REPORT = { + totalTasks: 20, + doneTasks: 5, +}; + +export const MOCK_INVITE_LINK = 'https://coworkers.app/invite/abc123'; diff --git a/src/app/[teamid]/_domain/hooks/useKanbanDnd.ts b/src/app/[teamid]/_domain/hooks/useKanbanDnd.ts new file mode 100644 index 0000000..6ce9aa8 --- /dev/null +++ b/src/app/[teamid]/_domain/hooks/useKanbanDnd.ts @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from '@dnd-kit/core'; +import { arrayMove } from '@dnd-kit/sortable'; +import type { KanbanTask, KanbanStatus } from '../interfaces/team'; + +// 드래그 시작으로 인식하는 최소 이동 거리(px) +const DRAG_ACTIVATION_DISTANCE = 8; + +export function useKanbanDnd( + tasks: KanbanTask[], + setTasks: React.Dispatch>, + columnIds: KanbanStatus[], +) { + const [activeTask, setActiveTask] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE }, + }), + ); + + const handleDragStart = (event: DragStartEvent) => { + setActiveTask(tasks.find((t) => t.id === String(event.active.id)) ?? null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveTask(null); + + if (!over || active.id === over.id) return; + + const activeId = String(active.id); + const overId = String(over.id); + + // over가 컬럼인 경우 (빈 컬럼에 드롭) + const isOverColumn = columnIds.includes(overId as KanbanStatus); + if (isOverColumn) { + setTasks((prev) => + prev.map((t) => (t.id === activeId ? { ...t, status: overId as KanbanStatus } : t)), + ); + return; + } + + // over가 다른 태스크인 경우 + const overTaskItem = tasks.find((t) => t.id === overId); + const activeTaskItem = tasks.find((t) => t.id === activeId); + if (!overTaskItem || !activeTaskItem) return; + + if (activeTaskItem.status === overTaskItem.status) { + // 같은 컬럼 내 순서 변경 + setTasks((prev) => { + const colTasks = prev.filter((t) => t.status === activeTaskItem.status); + const otherTasks = prev.filter((t) => t.status !== activeTaskItem.status); + const oldIdx = colTasks.findIndex((t) => t.id === activeId); + const newIdx = colTasks.findIndex((t) => t.id === overId); + return [...otherTasks, ...arrayMove(colTasks, oldIdx, newIdx)]; + }); + } else { + // 다른 컬럼으로 이동 + setTasks((prev) => + prev.map((t) => (t.id === activeId ? { ...t, status: overTaskItem.status } : t)), + ); + } + }; + + return { + activeTask, + sensors, + handleDragStart, + handleDragEnd, + }; +} diff --git a/src/app/[teamid]/_domain/hooks/useKanbanTasks.ts b/src/app/[teamid]/_domain/hooks/useKanbanTasks.ts new file mode 100644 index 0000000..6cb3ca5 --- /dev/null +++ b/src/app/[teamid]/_domain/hooks/useKanbanTasks.ts @@ -0,0 +1,135 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { useQueries } from '@tanstack/react-query'; +import type { KanbanTask, KanbanStatus, TaskItem } from '../interfaces/team'; +import type { TaskList } from '../apis/types'; +import { taskListQueryOptions } from '../queries/useTaskListQuery'; +import { useCreateTaskListMutation } from '../queries/useCreateTaskListMutation'; +import { useDeleteTaskListMutation } from '../queries/useDeleteTaskListMutation'; + +function getTodayDateString(): string { + return new Date().toISOString().split('T')[0]; +} + +function deriveStatus(items: TaskItem[]): KanbanStatus { + if (items.length === 0) return 'todo'; + const doneCount = items.filter((item) => item.checked).length; + if (doneCount === 0) return 'todo'; + if (doneCount === items.length) return 'done'; + return 'inProgress'; +} + +export function useKanbanTasks( + groupId: number, + teamId: string, + taskLists: Omit[], +) { + const router = useRouter(); + const today = getTodayDateString(); + + // 각 할 일 목록의 태스크를 병렬로 조회 + const taskListQueries = useQueries({ + queries: taskLists.map((tl) => taskListQueryOptions(groupId, tl.id, today)), + }); + + // API 데이터 → KanbanTask 변환 (목록 ID와 쿼리 갱신 시각 기준으로 메모이제이션) + const taskListIds = taskLists.map((tl) => tl.id).join(','); + const queriesKey = taskListQueries.map((q) => q.dataUpdatedAt).join(','); + + const computedTasks = useMemo(() => { + return taskLists.map((tl, i) => { + const apiTasks = taskListQueries[i]?.data?.tasks ?? []; + const items: TaskItem[] = apiTasks.map((task) => ({ + id: String(task.id), + text: task.name, + checked: task.doneAt !== null, + })); + return { + id: String(tl.id), + title: tl.name, + items, + status: deriveStatus(items), + }; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [taskListIds, queriesKey]); + + // DnD를 위한 로컬 상태 (API 데이터 변경 시 초기화) + const [prevComputed, setPrevComputed] = useState(computedTasks); + const [tasks, setTasks] = useState(computedTasks); + + if (prevComputed !== computedTasks) { + setPrevComputed(computedTasks); + setTasks(computedTasks); + } + + // 할 일 목록 추가 모달 상태 + const [addingStatus, setAddingStatus] = useState(null); + const [newListTitle, setNewListTitle] = useState(''); + + const createTaskListMutation = useCreateTaskListMutation(groupId); + const deleteTaskListMutation = useDeleteTaskListMutation(groupId); + + // 카드 클릭 시 할 일 목록 상세 페이지로 이동 + const handleCardClick = useCallback( + (taskId: string) => { + router.push(`/${teamId}/tasks/${taskId}`); + }, + [router, teamId], + ); + + // 할 일 목록 삭제 + const handleDeleteTask = useCallback( + (taskId: string) => { + deleteTaskListMutation.mutate(Number(taskId)); + }, + [deleteTaskListMutation], + ); + + // 태스크 아이템 체크 상태 변경은 할 일 목록 상세 페이지에서 처리하므로 빈 함수 + const handleItemCheckedChange = useCallback(() => {}, []); + + // 할 일 목록 추가 모달 열기 + const handleAddTask = useCallback((status: KanbanStatus) => { + setAddingStatus(status); + }, []); + + // 새 할 일 목록 생성 + const handleAddListSubmit = useCallback(async () => { + if (!newListTitle.trim() || !addingStatus) return; + try { + await createTaskListMutation.mutateAsync(newListTitle.trim()); + setNewListTitle(''); + setAddingStatus(null); + } catch { + // 에러는 상위에서 처리 + } + }, [newListTitle, addingStatus, createTaskListMutation]); + + const handleAddListClose = useCallback(() => { + setAddingStatus(null); + setNewListTitle(''); + }, []); + + const handleNewListTitleChange = useCallback((value: string) => { + setNewListTitle(value); + }, []); + + // 수정 기능은 할 일 목록 상세 페이지에서 처리 + const handleUpdateTask = useCallback(() => {}, []); + + return { + tasks, + setTasks, + addingStatus, + newListTitle, + handleItemCheckedChange, + handleCardClick, + handleDeleteTask, + handleUpdateTask, + handleAddTask, + handleAddListSubmit, + handleAddListClose, + handleNewListTitleChange, + }; +} diff --git a/src/app/[teamid]/_domain/interfaces/team.ts b/src/app/[teamid]/_domain/interfaces/team.ts new file mode 100644 index 0000000..8e04562 --- /dev/null +++ b/src/app/[teamid]/_domain/interfaces/team.ts @@ -0,0 +1,26 @@ +export type KanbanStatus = 'todo' | 'inProgress' | 'done'; + +export interface TeamMember { + id: string; + name: string; + email: string; + imageUrl?: string; +} + +export interface TaskItem { + id: string; + text: string; + checked: boolean; +} + +export interface KanbanTask { + id: string; + title: string; + items: TaskItem[]; + status: KanbanStatus; +} + +export interface MockTeam { + id: string; + name: string; +} diff --git a/src/app/[teamid]/_domain/queries/queryKeys.ts b/src/app/[teamid]/_domain/queries/queryKeys.ts new file mode 100644 index 0000000..b87a011 --- /dev/null +++ b/src/app/[teamid]/_domain/queries/queryKeys.ts @@ -0,0 +1,13 @@ +export const teamGroupKeys = { + all: ['groups'] as const, + details: () => [...teamGroupKeys.all, 'detail'] as const, + detail: (groupId: number) => [...teamGroupKeys.details(), groupId] as const, + tasks: (groupId: number, date?: string) => + [...teamGroupKeys.all, groupId, 'tasks', date] as const, + invitation: (groupId: number) => [...teamGroupKeys.all, groupId, 'invitation'] as const, +}; + +export const taskListKeys = { + detail: (groupId: number, taskListId: number, date?: string) => + ['groups', groupId, 'task-lists', taskListId, date] as const, +}; diff --git a/src/app/[teamid]/_domain/queries/useCreateTaskListMutation.ts b/src/app/[teamid]/_domain/queries/useCreateTaskListMutation.ts new file mode 100644 index 0000000..51fe2cb --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useCreateTaskListMutation.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createTaskList } from '../apis/taskList'; +import { teamGroupKeys } from './queryKeys'; + +export function useCreateTaskListMutation(groupId: number) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (name: string) => createTaskList(groupId, name), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: teamGroupKeys.detail(groupId) }); + }, + }); +} diff --git a/src/app/[teamid]/_domain/queries/useDeleteGroupMutation.ts b/src/app/[teamid]/_domain/queries/useDeleteGroupMutation.ts new file mode 100644 index 0000000..b18827f --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useDeleteGroupMutation.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteGroup } from '../apis/group'; +import { teamGroupKeys } from './queryKeys'; +import { currentUserKeys } from '@/shared/queries/user/useCurrentUserQuery'; + +export function useDeleteGroupMutation(groupId: number) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => deleteGroup(groupId), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: teamGroupKeys.all }); + // 유저 멤버십 목록도 갱신하여 사이드바에서 삭제된 팀이 사라지도록 처리 + void queryClient.invalidateQueries({ queryKey: currentUserKeys.all }); + }, + }); +} diff --git a/src/app/[teamid]/_domain/queries/useDeleteTaskListMutation.ts b/src/app/[teamid]/_domain/queries/useDeleteTaskListMutation.ts new file mode 100644 index 0000000..d7610c8 --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useDeleteTaskListMutation.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteTaskList } from '../apis/taskList'; +import { teamGroupKeys } from './queryKeys'; + +export function useDeleteTaskListMutation(groupId: number) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (taskListId: number) => deleteTaskList(groupId, taskListId), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: teamGroupKeys.detail(groupId) }); + }, + }); +} diff --git a/src/app/[teamid]/_domain/queries/useGroupInvitationQuery.ts b/src/app/[teamid]/_domain/queries/useGroupInvitationQuery.ts new file mode 100644 index 0000000..b4701f4 --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useGroupInvitationQuery.ts @@ -0,0 +1,18 @@ +import { queryOptions, useQuery } from '@tanstack/react-query'; +import { getGroupInvitation } from '../apis/group'; +import { teamGroupKeys } from './queryKeys'; + +export function groupInvitationQueryOptions(groupId: number) { + return queryOptions({ + queryKey: teamGroupKeys.invitation(groupId), + queryFn: () => getGroupInvitation(groupId), + staleTime: 0, + }); +} + +export function useGroupInvitationQuery(groupId: number, enabled: boolean) { + return useQuery({ + ...groupInvitationQueryOptions(groupId), + enabled, + }); +} diff --git a/src/app/[teamid]/_domain/queries/useGroupQuery.ts b/src/app/[teamid]/_domain/queries/useGroupQuery.ts new file mode 100644 index 0000000..a46cf21 --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useGroupQuery.ts @@ -0,0 +1,14 @@ +import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; +import { getGroup } from '../apis/group'; +import { teamGroupKeys } from './queryKeys'; + +export function groupQueryOptions(groupId: number) { + return queryOptions({ + queryKey: teamGroupKeys.detail(groupId), + queryFn: () => getGroup(groupId), + }); +} + +export function useGroupQuery(groupId: number) { + return useSuspenseQuery(groupQueryOptions(groupId)); +} diff --git a/src/app/[teamid]/_domain/queries/useGroupTasksQuery.ts b/src/app/[teamid]/_domain/queries/useGroupTasksQuery.ts new file mode 100644 index 0000000..9e34039 --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useGroupTasksQuery.ts @@ -0,0 +1,14 @@ +import { queryOptions, useQuery } from '@tanstack/react-query'; +import { getGroupTasks } from '../apis/group'; +import { teamGroupKeys } from './queryKeys'; + +export function groupTasksQueryOptions(groupId: number, date?: string) { + return queryOptions({ + queryKey: teamGroupKeys.tasks(groupId, date), + queryFn: () => getGroupTasks(groupId, date), + }); +} + +export function useGroupTasksQuery(groupId: number, date?: string) { + return useQuery(groupTasksQueryOptions(groupId, date)); +} diff --git a/src/app/[teamid]/_domain/queries/useRemoveMemberMutation.ts b/src/app/[teamid]/_domain/queries/useRemoveMemberMutation.ts new file mode 100644 index 0000000..cf5fc33 --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useRemoveMemberMutation.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { removeGroupMember } from '@/shared/apis/groups/member'; +import { teamGroupKeys } from './queryKeys'; + +export function useRemoveMemberMutation(groupId: number) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (memberUserId: number) => removeGroupMember(groupId, memberUserId), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: teamGroupKeys.detail(groupId) }); + }, + }); +} diff --git a/src/app/[teamid]/_domain/queries/useTaskListQuery.ts b/src/app/[teamid]/_domain/queries/useTaskListQuery.ts new file mode 100644 index 0000000..d834397 --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useTaskListQuery.ts @@ -0,0 +1,14 @@ +import { queryOptions, useQuery } from '@tanstack/react-query'; +import { getTaskList } from '../apis/taskList'; +import { taskListKeys } from './queryKeys'; + +export function taskListQueryOptions(groupId: number, taskListId: number, date?: string) { + return queryOptions({ + queryKey: taskListKeys.detail(groupId, taskListId, date), + queryFn: () => getTaskList(groupId, taskListId, date), + }); +} + +export function useTaskListQuery(groupId: number, taskListId: number, date?: string) { + return useQuery(taskListQueryOptions(groupId, taskListId, date)); +} diff --git a/src/app/[teamid]/_domain/queries/useUpdateGroupMutation.ts b/src/app/[teamid]/_domain/queries/useUpdateGroupMutation.ts new file mode 100644 index 0000000..994eac9 --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useUpdateGroupMutation.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateGroup } from '../apis/group'; +import type { Group } from '../apis/types'; +import { teamGroupKeys } from './queryKeys'; + +export function useUpdateGroupMutation(groupId: number) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: Parameters[1]) => updateGroup(groupId, body), + onSuccess: (updatedGroup) => { + queryClient.setQueryData(teamGroupKeys.detail(groupId), (prev: Group | undefined) => + prev ? { ...prev, ...updatedGroup } : updatedGroup, + ); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: teamGroupKeys.detail(groupId) }); + }, + }); +} diff --git a/src/app/[teamid]/_domain/styles/common.module.css b/src/app/[teamid]/_domain/styles/common.module.css new file mode 100644 index 0000000..e9f86bb --- /dev/null +++ b/src/app/[teamid]/_domain/styles/common.module.css @@ -0,0 +1,16 @@ +.flexCol { + display: flex; + flex-direction: column; +} + +.flexColCenter { + display: flex; + flex-direction: column; + align-items: center; +} + +.flexCenter { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/app/[teamid]/layout.tsx b/src/app/[teamid]/layout.tsx new file mode 100644 index 0000000..53ca7db --- /dev/null +++ b/src/app/[teamid]/layout.tsx @@ -0,0 +1,15 @@ +import SidebarWrapper from './_domain/components/Team/SidebarWrapper'; +import TeamNavClient from './_domain/components/Team/TeamNavClient'; +import styles from './page.module.css'; + +export default function TeamLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+ +
{children}
+
+ ); +} diff --git a/src/app/[teamid]/page.module.css b/src/app/[teamid]/page.module.css new file mode 100644 index 0000000..6ea7c63 --- /dev/null +++ b/src/app/[teamid]/page.module.css @@ -0,0 +1,99 @@ +.page { + display: flex; + min-height: 100vh; + background: var(--color-background-secondary); +} + +.mainContents { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: hidden; /* Added to prevent horizontal scroll */ + min-width: 0; + width: 100%; /* Explicitly set width to 100% */ + box-sizing: border-box; /* Ensure padding is included in the width */ + margin-left: 24px; +} + +/* Apply max-width to direct children of mainContents to prevent overflow */ +.mainContents > * { + max-width: 100%; + box-sizing: border-box; +} + +/* Default styles (Desktop) */ +.desktopSidebar { + display: block; /* Show desktop sidebar */ + /* fixed 포지션 사이드바가 flex 흐름에서 공간을 차지하지 않으므로, spacer 역할을 위해 너비 명시 */ + width: 270px; + min-width: 270px; + flex-shrink: 0; +} +/* Tablet styles */ +@media (min-width: 768px) and (max-width: 1279px) { + .page { + flex-direction: column; + } + + .desktopSidebar { + display: none; + } + + .mainContents { + margin-left: 0; + } +} + +/* Mobile styles */ +@media (max-width: 767px) { + .page { + flex-direction: column; + } + + .desktopSidebar { + display: none; + } + + .mainContents { + width: 100%; + margin-left: 0; + } +} + +.kanbanResponsiveContainer { + display: flex; + gap: 16px; /* Space between columns */ + width: 100%; /* Ensure it takes full width */ + box-sizing: border-box; + overflow-x: auto; /* Allow horizontal scroll if columns don't fit */ + padding-bottom: 8px; /* For scrollbar visibility */ +} + +/* Tablet specific rules for kanbanResponsiveContainer */ +@media (min-width: 768px) and (max-width: 1279px) { + .kanbanResponsiveContainer { + flex-wrap: nowrap; /* Prevent wrapping on tablet */ + justify-content: space-between; /* Distribute items evenly */ + } + + .kanbanResponsiveContainer > * { + /* Target direct children (Kanban columns) */ + flex: 1 1 auto; /* Allow items to grow and shrink */ + min-width: 0; /* Crucial for flex items to shrink below their content size */ + max-width: calc(33.33% - 12px); /* Distribute width for 3 columns, accounting for gap */ + } +} + +/* Mobile specific rules for kanbanResponsiveContainer */ +@media (max-width: 767px) { + .kanbanResponsiveContainer { + flex-wrap: wrap; /* Always wrap on mobile */ + justify-content: center; + } + + .kanbanResponsiveContainer > * { + /* Target direct children (Kanban columns) */ + width: 100%; /* Each column takes full width */ + } +} diff --git a/src/app/[teamid]/page.tsx b/src/app/[teamid]/page.tsx new file mode 100644 index 0000000..88b0c18 --- /dev/null +++ b/src/app/[teamid]/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { useParams } from 'next/navigation'; + +const TeamDashboard = dynamic(() => import('./_domain/components/Team/TeamDashboard'), { + ssr: false, +}); + +export default function TeamPage() { + const params = useParams<{ teamid: string }>(); + + return ; +} diff --git a/src/app/addteam/_domain/apis/image.ts b/src/app/addteam/_domain/apis/image.ts new file mode 100644 index 0000000..bb96b66 --- /dev/null +++ b/src/app/addteam/_domain/apis/image.ts @@ -0,0 +1 @@ +export { uploadImage } from '@/shared/apis/images'; diff --git a/src/app/addteam/_domain/components/JoinTeamCard.tsx b/src/app/addteam/_domain/components/JoinTeamCard.tsx index 2d7b9ae..d062f30 100644 --- a/src/app/addteam/_domain/components/JoinTeamCard.tsx +++ b/src/app/addteam/_domain/components/JoinTeamCard.tsx @@ -1,21 +1,41 @@ 'use client'; +import { type FormEvent } from 'react'; import { BaseButton } from '@/components/Button/base'; import { Input } from '@/components/input'; +import type { CreateTeamFeedback } from '../interfaces/feedback'; import joinCardStyles from './JoinTeamCard.module.css'; +import feedbackStyles from './FeedbackMessage.module.css'; import clsx from 'clsx'; import commonStyles from '../styles/common.module.css'; +const JOIN_TEAM_FEEDBACK_ID = 'join-team-helper-text'; + interface JoinTeamCardProps { teamLink: string; + disabled: boolean; + feedback: CreateTeamFeedback | null; onTeamLinkChange: (value: string) => void; + onSubmit: () => void | Promise; } -export default function JoinTeamCard({ teamLink, onTeamLinkChange }: JoinTeamCardProps) { - const helperTextId = 'join-team-helper-text'; +export default function JoinTeamCard({ + teamLink, + disabled, + feedback, + onTeamLinkChange, + onSubmit, +}: JoinTeamCardProps) { + const handleFormSubmit = (event: FormEvent) => { + event.preventDefault(); + void onSubmit(); + }; return ( -
+

팀 참여하기

@@ -26,17 +46,41 @@ export default function JoinTeamCard({ teamLink, onTeamLinkChange }: JoinTeamCar id="team-link" value={teamLink} onChange={(event) => onTeamLinkChange(event.target.value)} - aria-describedby={helperTextId} + aria-describedby={JOIN_TEAM_FEEDBACK_ID} placeholder="팀 링크를 입력해주세요." className={joinCardStyles.teamLinkInput} />
- 참여하기 + + 참여하기 + -

- 공유받은 팀 링크를 입력해 참여할 수 있어요. -

-
+ {feedback ? ( + feedback.type === 'error' ? ( + + ) : ( +

+ {feedback.message} +

+ ) + ) : ( +

+ 공유받은 팀 링크를 입력해 참여할 수 있어요. +

+ )} + ); } diff --git a/src/app/addteam/_domain/hooks/useJoinTeam.ts b/src/app/addteam/_domain/hooks/useJoinTeam.ts new file mode 100644 index 0000000..f0fcb3e --- /dev/null +++ b/src/app/addteam/_domain/hooks/useJoinTeam.ts @@ -0,0 +1,36 @@ +import { useAcceptInvitationMutation } from '@/shared/queries/groups/useAcceptInvitationMutation'; +import { useCurrentUserQuery } from '@/shared/queries/user/useCurrentUserQuery'; + +// 팀 링크(URL) 또는 토큰 문자열에서 inviteToken을 추출 +function extractToken(teamLink: string): string { + try { + const url = new URL(teamLink.trim()); + const token = url.searchParams.get('token'); + if (token) return token; + } catch { + // URL 파싱 실패 시 입력값 자체를 토큰으로 사용 + } + return teamLink.trim(); +} + +export function useJoinTeam() { + const acceptInvitationMutation = useAcceptInvitationMutation(); + const { data: currentUser } = useCurrentUserQuery(); + + const joinTeam = async (teamLink: string) => { + const token = extractToken(teamLink); + if (!token) { + throw new Error('팀 링크를 입력해주세요.'); + } + const userEmail = currentUser?.email; + if (!userEmail) { + throw new Error('로그인이 필요합니다.'); + } + return acceptInvitationMutation.mutateAsync({ userEmail, token }); + }; + + return { + ...acceptInvitationMutation, + joinTeam, + }; +} diff --git a/src/app/addteam/_domain/queries/useUploadImageMutation.ts b/src/app/addteam/_domain/queries/useUploadImageMutation.ts new file mode 100644 index 0000000..a50b6ca --- /dev/null +++ b/src/app/addteam/_domain/queries/useUploadImageMutation.ts @@ -0,0 +1,8 @@ +import { useMutation } from '@tanstack/react-query'; +import { uploadImage } from '../apis/image'; + +export function useUploadImageMutation() { + return useMutation({ + mutationFn: uploadImage, + }); +} diff --git a/src/app/addteam/create/page.tsx b/src/app/addteam/create/page.tsx index 13d0b05..ccecf8b 100644 --- a/src/app/addteam/create/page.tsx +++ b/src/app/addteam/create/page.tsx @@ -23,7 +23,7 @@ export default function CreateTeamPage() { const group = await createTeam(teamName); setTeamName(''); setCreateTeamFeedback({ type: 'success', message: CREATE_TEAM_MESSAGES.success }); - router.push(`/teams/${group.id}`); + router.push(`/${group.id}`); } catch (error) { setCreateTeamFeedback({ type: 'error', message: getCreateTeamFailureMessage(error) }); } diff --git a/src/app/addteam/join/page.tsx b/src/app/addteam/join/page.tsx index 504affc..1ecac56 100644 --- a/src/app/addteam/join/page.tsx +++ b/src/app/addteam/join/page.tsx @@ -1,10 +1,56 @@ 'use client'; -import { useState } from 'react'; +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import JoinTeamCard from '../_domain/components/JoinTeamCard'; +import { useJoinTeam } from '../_domain/hooks/useJoinTeam'; +import type { CreateTeamFeedback } from '../_domain/interfaces/feedback'; -export default function JoinTeamPage() { - const [teamLink, setTeamLink] = useState(''); +function JoinTeamPageContent() { + const searchParams = useSearchParams(); + // 초대 링크로 직접 접근 시 URL의 현재 주소를 입력값으로 자동 설정 + const [teamLink, setTeamLink] = useState(() => { + if (typeof window === 'undefined') return ''; + return searchParams.get('token') ? window.location.href : ''; + }); + const [feedback, setFeedback] = useState(null); + const { joinTeam, isPending } = useJoinTeam(); + const router = useRouter(); + + const isSubmitDisabled = !teamLink.trim() || isPending; + + const handleSubmit = async () => { + if (isSubmitDisabled) return; + + try { + const group = await joinTeam(teamLink); + setFeedback({ type: 'success', message: '팀에 참여했습니다.' }); + router.push(`/${group.id}`); + } catch { + setFeedback({ type: 'error', message: '유효하지 않은 팀 링크입니다.' }); + } + }; - return ; + const handleTeamLinkChange = (value: string) => { + setTeamLink(value); + setFeedback(null); + }; + + return ( + + ); +} + +export default function JoinTeamPage() { + return ( + + + + ); } diff --git a/src/app/api/images/upload/route.ts b/src/app/api/images/upload/route.ts new file mode 100644 index 0000000..fde689b --- /dev/null +++ b/src/app/api/images/upload/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { fetchApiServer } from '@/shared/apis/fetchApi.server'; + +// 멀티파트 복잡도로 인해 일반 프록시에서 처리하지 않고 전용 라우트로 분리 +export async function POST(req: NextRequest) { + try { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + if (!accessToken) { + return NextResponse.json({ message: '인증이 필요합니다.' }, { status: 401 }); + } + + const formData = await req.formData(); + + const response = await fetchApiServer('/images/upload', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + return NextResponse.json(error, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json({ message: '이미지 업로드 중 오류가 발생했습니다.' }, { status: 500 }); + } +} diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index d2b0137..1aa2c62 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -52,7 +52,8 @@ async function proxyRequest( const pathWithQuery = searchParams ? `${backendPath}?${searchParams}` : backendPath; const method = req.method; - const isBodyless = method === 'GET' || method === 'HEAD'; + // const isBodyless = method === 'GET' || method === 'HEAD' + const isBodyless = method === 'GET' || method === 'HEAD' || method === 'DELETE'; // 수정 요청코드 1 (DELETE 추가) const contentType = req.headers.get('content-type') ?? ''; const body = isBodyless ? undefined : await req.text(); @@ -128,13 +129,15 @@ async function handleProxy(req: NextRequest, params: { path: string[] }) { const response = await proxyRequest(req, backendPath, accessToken); const responseData = await response.text(); - return new NextResponse(responseData, { + return new NextResponse(responseData || null, { + // 수정 요청 코드 2 (null 일때도 응답 반환) status: response.status, headers: { 'Content-Type': response.headers.get('content-type') ?? 'application/json', }, }); - } catch { + } catch (error) { + console.error('[proxy] 요청 처리 중 오류:', error); // 수정 요청 코드 3 (에러 내용 명시) return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 }); } } diff --git a/src/components/sidebar/TabletHeader.tsx b/src/components/sidebar/TabletHeader.tsx new file mode 100644 index 0000000..ae8166e --- /dev/null +++ b/src/components/sidebar/TabletHeader.tsx @@ -0,0 +1,32 @@ +'use client'; + +import Image from 'next/image'; +import styles from './styles/TabletHeader.module.css'; +import logoLarge from '@/assets/logos/logoLarge.svg'; +import hamburgerIcon from '@/assets/buttons/hamburger/hamburger.svg'; // Assuming a hamburger icon exists +import ProfileImage from '@/components/profile-img/ProfileImage'; // Import ProfileImage +import { useCurrentUserQuery } from '@/shared/queries/user/useCurrentUserQuery'; // Import the query + +export default function TabletHeader() { + // No props needed for profileImage + const { data: currentUser } = useCurrentUserQuery(); // Fetch user data internally + + const handleHamburgerClick = () => { + console.log('Hamburger menu clicked'); + // Implement logic to open a drawer/menu + }; + + return ( +
+ +
+ COWORKERS +
+
+ +
+
+ ); +} diff --git a/src/components/sidebar/index.ts b/src/components/sidebar/index.ts index bddd994..028efec 100644 --- a/src/components/sidebar/index.ts +++ b/src/components/sidebar/index.ts @@ -4,4 +4,5 @@ export { default as Sidebar } from './Sidebar'; export { default as SidebarAddButton } from './SidebarAddButton'; export { default as SidebarButton } from './SidebarButton'; export { default as SidebarTeamSelect } from './SidebarTeamSelect'; +export { default as TabletHeader } from './TabletHeader'; // Added export export type { SidebarButtonProps } from './types/types'; diff --git a/src/components/sidebar/styles/TabletHeader.module.css b/src/components/sidebar/styles/TabletHeader.module.css new file mode 100644 index 0000000..9f8a52d --- /dev/null +++ b/src/components/sidebar/styles/TabletHeader.module.css @@ -0,0 +1,31 @@ +.tabletHeader { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: 60px; /* Adjust height as needed */ + padding: 0 16px; + background: var(--color-background-inverse); /* Assuming white background */ + border-bottom: 1px solid var(--color-background-tertiary); +} + +.hamburgerButton { + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.logo { + /* Logo might need specific sizing if it's an SVG */ + display: flex; + align-items: center; +} + +.profileArea { + display: flex; + align-items: center; +} diff --git a/src/shared/apis/groups/http.ts b/src/shared/apis/groups/http.ts index 63fdade..8943a24 100644 --- a/src/shared/apis/groups/http.ts +++ b/src/shared/apis/groups/http.ts @@ -1,11 +1,20 @@ -import { fetchApi } from '../fetchApi.server'; - interface RequestErrorContext { message: string; path: string; method: string; } +function buildProxyUrl(path: string): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `/api/proxy${normalizedPath}`; +} + +function buildHeaders(options?: RequestInit): HeadersInit { + const method = (options?.method ?? 'GET').toUpperCase(); + const needsContentType = ['POST', 'PATCH', 'PUT'].includes(method) && options?.body !== undefined; + return needsContentType ? { 'Content-Type': 'application/json' } : {}; +} + function assertOk(response: Response, context: RequestErrorContext) { if (!response.ok) { throw new Error( @@ -23,7 +32,13 @@ export async function requestJson( message: string, options?: RequestInit, ): Promise { - const response = await fetchApi(path, options); + const response = await fetch(buildProxyUrl(path), { + ...options, + headers: { + ...buildHeaders(options), + ...(options?.headers ?? {}), + }, + }); assertOk(response, { message, path, @@ -37,7 +52,13 @@ export async function requestVoid( message: string, options?: RequestInit, ): Promise { - const response = await fetchApi(path, options); + const response = await fetch(buildProxyUrl(path), { + ...options, + headers: { + ...buildHeaders(options), + ...(options?.headers ?? {}), + }, + }); assertOk(response, { message, path, diff --git a/src/shared/apis/groups/types.ts b/src/shared/apis/groups/types.ts index c44120a..c699701 100644 --- a/src/shared/apis/groups/types.ts +++ b/src/shared/apis/groups/types.ts @@ -12,6 +12,7 @@ export interface Group { } export interface AcceptInvitationBody { + userEmail: string; token: string; } diff --git a/src/shared/apis/images.ts b/src/shared/apis/images.ts new file mode 100644 index 0000000..6c639be --- /dev/null +++ b/src/shared/apis/images.ts @@ -0,0 +1,19 @@ +interface ImageUploadResponse { + url: string; +} + +export async function uploadImage(file: File): Promise { + const formData = new FormData(); + formData.append('image', file); + + const response = await fetch('/api/images/upload', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`이미지 업로드 실패 (status: ${response.status})`); + } + + return response.json() as Promise; +} diff --git a/src/shared/apis/user.ts b/src/shared/apis/user.ts new file mode 100644 index 0000000..1b3a0bf --- /dev/null +++ b/src/shared/apis/user.ts @@ -0,0 +1,34 @@ +import { requestJson } from './groups/http'; + +export type UserRole = 'ADMIN' | 'MEMBER'; + +export interface UserMembershipGroup { + id: number; + name: string; + image: string | null; + teamId: string; +} + +export interface UserMembership { + userId: number; + groupId: number; + role: UserRole; + group: UserMembershipGroup; +} + +export interface User { + id: number; + teamId: string; + nickname: string; + email: string; + image: string | null; + memberships: UserMembership[]; +} + +const USER_ERROR_MESSAGE = { + fetch: '유저 정보 조회 실패', +} as const; + +export function getCurrentUser(): Promise { + return requestJson('/user', USER_ERROR_MESSAGE.fetch); +} diff --git a/src/shared/apis/user/userApi.ts b/src/shared/apis/user/userApi.ts index f60205e..7bda703 100644 --- a/src/shared/apis/user/userApi.ts +++ b/src/shared/apis/user/userApi.ts @@ -1,5 +1,3 @@ -import { fetchApi } from '../fetchApi.server'; -import { BASE_URL, TEAM_ID } from '../config'; import type { UserResponse, UpdateUserRequest, @@ -11,7 +9,7 @@ import type { /** 현재 유저 정보 조회 */ export async function getUser(): Promise { - const response = await fetchApi('/user'); + const response = await fetch('/api/proxy/user'); if (!response.ok) { throw new Error('유저 정보를 불러오는데 실패했습니다.'); @@ -22,8 +20,9 @@ export async function getUser(): Promise { /** 유저 정보 수정 */ export async function updateUser(data: UpdateUserRequest): Promise { - const response = await fetchApi('/user', { + const response = await fetch('/api/proxy/user', { method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); @@ -36,7 +35,7 @@ export async function updateUser(data: UpdateUserRequest): Promise { - const response = await fetchApi('/user', { + const response = await fetch('/api/proxy/user', { method: 'DELETE', }); @@ -47,8 +46,9 @@ export async function deleteUser(): Promise { /** 비밀번호 변경 */ export async function changePassword(data: ChangePasswordRequest): Promise { - const response = await fetchApi('/user/password', { + const response = await fetch('/api/proxy/user/password', { method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); @@ -64,7 +64,7 @@ export async function uploadImage(file: File): Promise { const formData = new FormData(); formData.append('image', file); - const response = await fetch(`${BASE_URL}/${TEAM_ID}/images/upload`, { + const response = await fetch('/api/images/upload', { method: 'POST', body: formData, }); diff --git a/src/shared/queries/groups/queryKeys.ts b/src/shared/queries/groups/queryKeys.ts index 0989ce0..c8d029d 100644 --- a/src/shared/queries/groups/queryKeys.ts +++ b/src/shared/queries/groups/queryKeys.ts @@ -3,4 +3,5 @@ export const groupsKeys = { details: () => [...groupsKeys.all, 'detail'] as const, detail: (groupId: number) => [...groupsKeys.details(), groupId] as const, create: () => [...groupsKeys.all, 'create'] as const, + acceptInvitation: () => [...groupsKeys.all, 'accept-invitation'] as const, }; diff --git a/src/shared/queries/groups/useAcceptInvitationMutation.ts b/src/shared/queries/groups/useAcceptInvitationMutation.ts new file mode 100644 index 0000000..c340b5a --- /dev/null +++ b/src/shared/queries/groups/useAcceptInvitationMutation.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { acceptInvitation } from '@/shared/apis/groups/invitation'; +import { groupsKeys } from './queryKeys'; +import { currentUserKeys } from '@/shared/queries/user/useCurrentUserQuery'; + +export function useAcceptInvitationMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: groupsKeys.acceptInvitation(), + mutationFn: acceptInvitation, + onSuccess: (group) => { + queryClient.setQueryData(groupsKeys.detail(group.id), group); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: groupsKeys.all }); + // 사이드바가 사용하는 유저 정보(memberships)도 갱신 + void queryClient.invalidateQueries({ queryKey: currentUserKeys.all }); + }, + }); +} diff --git a/src/shared/queries/groups/useCreateGroupMutation.ts b/src/shared/queries/groups/useCreateGroupMutation.ts index 90bb832..18db2be 100644 --- a/src/shared/queries/groups/useCreateGroupMutation.ts +++ b/src/shared/queries/groups/useCreateGroupMutation.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { createGroup } from '@/shared/apis/groups/group'; import { groupsKeys } from './queryKeys'; +import { currentUserKeys } from '@/shared/queries/user/useCurrentUserQuery'; export function useCreateGroupMutation() { const queryClient = useQueryClient(); @@ -13,6 +14,8 @@ export function useCreateGroupMutation() { }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: groupsKeys.all }); + // 사이드바가 사용하는 유저 정보(memberships)도 갱신 + void queryClient.invalidateQueries({ queryKey: currentUserKeys.all }); }, }); } diff --git a/src/shared/queries/user/useCurrentUserQuery.ts b/src/shared/queries/user/useCurrentUserQuery.ts new file mode 100644 index 0000000..eea4b2a --- /dev/null +++ b/src/shared/queries/user/useCurrentUserQuery.ts @@ -0,0 +1,23 @@ +import { queryOptions, useSuspenseQuery, useQuery } from '@tanstack/react-query'; +import { getCurrentUser } from '@/shared/apis/user'; + +export const currentUserKeys = { + all: ['user'] as const, + me: () => [...currentUserKeys.all, 'me'] as const, +}; + +export function currentUserQueryOptions() { + return queryOptions({ + queryKey: currentUserKeys.me(), + queryFn: () => getCurrentUser(), + staleTime: 5 * 60 * 1000, // 5분 + }); +} + +export function useCurrentUserQuery() { + return useQuery(currentUserQueryOptions()); +} + +export function useSuspenseCurrentUserQuery() { + return useSuspenseQuery(currentUserQueryOptions()); +}