From e1e16a24714634df8319ccaaad9c5e72315de987 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 20 Feb 2026 02:47:29 +0900 Subject: [PATCH 01/39] =?UTF-8?q?feat:=20=ED=8C=80=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[teamid]/_domain/styles/common.module.css | 16 ++++++++++ src/app/[teamid]/layout.tsx | 15 +++++++++ src/app/[teamid]/page.module.css | 31 +++++++++++++++++++ src/app/[teamid]/page.tsx | 5 +++ 4 files changed, 67 insertions(+) create mode 100644 src/app/[teamid]/_domain/styles/common.module.css create mode 100644 src/app/[teamid]/layout.tsx create mode 100644 src/app/[teamid]/page.module.css create mode 100644 src/app/[teamid]/page.tsx 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..198359d --- /dev/null +++ b/src/app/[teamid]/layout.tsx @@ -0,0 +1,15 @@ +import { MobileHeader, Sidebar } from '@/components/sidebar'; +import TeamSidebarDropdown from './_domain/components/Team/TeamSidebarDropdown'; +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..a8ffa4a --- /dev/null +++ b/src/app/[teamid]/page.module.css @@ -0,0 +1,31 @@ +.page { + display: flex; + min-height: 100vh; + background: var(--color-background-secondary); +} + +.mobileOnlyHeader { + display: none; +} + +.mainContents { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + min-width: 0; +} + +@media (max-width: 767px) { + .page { + flex-direction: column; + } + + .mobileOnlyHeader { + display: block; + } + + .mainContents { + width: 100%; + } +} diff --git a/src/app/[teamid]/page.tsx b/src/app/[teamid]/page.tsx new file mode 100644 index 0000000..b372e52 --- /dev/null +++ b/src/app/[teamid]/page.tsx @@ -0,0 +1,5 @@ +import TeamDashboard from './_domain/components/Team/TeamDashboard'; + +export default function TeamPage() { + return ; +} From bcd95fcf1c4da14f7f517bf82641621c8fba0091 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 20 Feb 2026 02:48:00 +0900 Subject: [PATCH 02/39] =?UTF-8?q?feat:=20=EC=B9=B8=EB=B0=98=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Kanban/KanbanBoard.module.css | 62 +++++++ .../_domain/components/Kanban/KanbanBoard.tsx | 175 ++++++++++++++++++ .../components/Kanban/KanbanColumn.module.css | 52 ++++++ .../components/Kanban/KanbanColumn.tsx | 52 ++++++ .../components/Kanban/KanbanItem.module.css | 36 ++++ .../_domain/components/Kanban/KanbanItem.tsx | 64 +++++++ 6 files changed, 441 insertions(+) create mode 100644 src/app/[teamid]/_domain/components/Kanban/KanbanBoard.module.css create mode 100644 src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx create mode 100644 src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css create mode 100644 src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx create mode 100644 src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css create mode 100644 src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx 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..121dc87 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.module.css @@ -0,0 +1,62 @@ +.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: flex; + gap: 24px; + align-items: flex-start; +} + +.addListButton { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 912px; + 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..df8e019 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from '@dnd-kit/core'; +import { arrayMove } from '@dnd-kit/sortable'; +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 { KanbanTask, KanbanStatus } from '../interfaces/team'; + +const KANBAN_COLUMNS: { id: KanbanStatus; label: string }[] = [ + { id: 'todo', label: '할 일' }, + { id: 'inProgress', label: '진행중' }, + { id: 'done', label: '완료' }, +]; + +interface KanbanBoardProps { + tasks: KanbanTask[]; + setTasks: React.Dispatch>; + teamId: string; +} + +export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProps) { + const router = useRouter(); + const [activeTask, setActiveTask] = useState(null); + const [isAddListOpen, setIsAddListOpen] = useState(false); + const [newListTitle, setNewListTitle] = useState(''); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); + + const getTasksByStatus = useCallback( + (status: KanbanStatus) => tasks.filter((t) => t.status === status), + [tasks], + ); + + const handleDragStart = (event: DragStartEvent) => { + const found = tasks.find((t) => t.id === String(event.active.id)); + setActiveTask(found ?? 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 overColumn = KANBAN_COLUMNS.find((c) => c.id === overId); + if (overColumn) { + setTasks((prev) => + prev.map((t) => (t.id === activeId ? { ...t, status: overColumn.id } : t)), + ); + return; + } + + // over가 다른 태스크인 경우 + const overTask = tasks.find((t) => t.id === overId); + const activeTask = tasks.find((t) => t.id === activeId); + if (!overTask || !activeTask) return; + + if (activeTask.status === overTask.status) { + // 같은 컬럼 내 순서 변경 + setTasks((prev) => { + const colTasks = prev.filter((t) => t.status === activeTask.status); + const otherTasks = prev.filter((t) => t.status !== activeTask.status); + const oldIdx = colTasks.findIndex((t) => t.id === activeId); + const newIdx = colTasks.findIndex((t) => t.id === overId); + const reordered = arrayMove(colTasks, oldIdx, newIdx); + return [...otherTasks, ...reordered]; + }); + } else { + // 다른 컬럼으로 이동 + setTasks((prev) => + prev.map((t) => (t.id === activeId ? { ...t, status: overTask.status } : t)), + ); + } + }; + + const handleItemCheckedChange = (taskId: string, itemId: string, checked: boolean) => { + setTasks((prev) => + prev.map((task) => + task.id === taskId + ? { + ...task, + items: task.items.map((item) => (item.id === itemId ? { ...item, checked } : item)), + } + : task, + ), + ); + }; + + const handleCardClick = (taskId: string) => { + router.push(`/${teamId}/tasks/${taskId}`); + }; + + const handleAddListSubmit = () => { + if (!newListTitle.trim()) return; + + const newTask: KanbanTask = { + id: `task-${Date.now()}`, + title: newListTitle.trim(), + status: 'todo', + items: [], + }; + + setTasks((prev) => [...prev, newTask]); + setNewListTitle(''); + setIsAddListOpen(false); + }; + + return ( +
+
+

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

+
+ + +
+ {KANBAN_COLUMNS.map((col) => ( + + ))} +
+ + + {activeTask && ( +
+ +
+ )} +
+
+ + + + setIsAddListOpen(false)} + onSubmit={handleAddListSubmit} + input={{ + props: { + value: newListTitle, + onChange: (e) => setNewListTitle(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..6e81950 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css @@ -0,0 +1,52 @@ +.column { + min-width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.columnHeader { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + background-color: var(--color-background-tertiary); + border-radius: 12px; + height: 38px; +} + +.columnTitle { + font-size: 16px; + font-weight: 600; + color: var(--color-text-primary); + margin-left: 24px; +} + +.columnCount { + display: flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + background: var(--color-background-tertiary); + font-size: 12px; + font-weight: 600; + color: var(--color-text-default); +} + +.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..62aad59 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx @@ -0,0 +1,52 @@ +'use client'; + +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 } from '../../interfaces/team'; + +const COLUMN_LABELS: Record = { + todo: '할 일', + inProgress: '진행중', + done: '완료', +}; + +interface KanbanColumnProps { + status: KanbanStatus; + tasks: KanbanTask[]; + onItemCheckedChange?: (taskId: string, itemId: string, checked: boolean) => void; + onCardClick?: (taskId: string) => void; +} + +export default function KanbanColumn({ + status, + tasks, + onItemCheckedChange, + onCardClick, +}: KanbanColumnProps) { + const { setNodeRef, isOver } = useDroppable({ id: status }); + const itemIds = tasks.map((t) => t.id); + + return ( +
+
+

{COLUMN_LABELS[status]}

+ {tasks.length} +
+ + +
+ {tasks.map((task) => ( + + ))} +
+
+
+ ); +} 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..b0d87e4 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css @@ -0,0 +1,36 @@ +.item { + position: relative; + display: flex; + flex-direction: column; + cursor: pointer; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + height: 20px; + cursor: grab; + color: var(--color-interaction-inactive); + font-size: 16px; + user-select: none; + margin-bottom: 4px; + opacity: 0; + transition: opacity 0.15s; +} + +.item:hover .dragHandle { + opacity: 1; +} + +.dragHandle:active { + cursor: grabbing; +} + +.dragDots { + letter-spacing: 2px; +} + +.todoCard { + flex: 1; +} 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..6142f1f --- /dev/null +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx @@ -0,0 +1,64 @@ +'use client'; + +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 } from '../interfaces/team'; + +interface KanbanItemProps { + task: KanbanTask; + onItemCheckedChange?: (taskId: string, itemId: string, checked: boolean) => void; + onCardClick?: (taskId: string) => void; +} + +export default function KanbanItem({ task, onItemCheckedChange, onCardClick }: KanbanItemProps) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: task.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + }; + + const handleContainerClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (!target.closest('button, input, label, a')) { + onCardClick?.(task.id); + } + }; + + return ( +
+
+ +
+ onItemCheckedChange(task.id, itemId, checked) + : undefined + } + className={styles.todoCard} + /> +
+ ); +} From c1aa5fd13b982ec380b784280cf77e70f11ab21a Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 20 Feb 2026 02:48:23 +0900 Subject: [PATCH 03/39] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=20=EC=B9=B4=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Member/MemberCard.module.css | 61 +++++++++++++++++++ .../_domain/components/Member/MemberCard.tsx | 25 ++++++++ .../Member/MemberSection.module.css | 48 +++++++++++++++ .../components/Member/MemberSection.tsx | 55 +++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 src/app/[teamid]/_domain/components/Member/MemberCard.module.css create mode 100644 src/app/[teamid]/_domain/components/Member/MemberCard.tsx create mode 100644 src/app/[teamid]/_domain/components/Member/MemberSection.module.css create mode 100644 src/app/[teamid]/_domain/components/Member/MemberSection.tsx 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..02d5f30 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Member/MemberCard.module.css @@ -0,0 +1,61 @@ +.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; +} + +.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..7a50184 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Member/MemberCard.tsx @@ -0,0 +1,25 @@ +import styles from './MemberCard.module.css'; +import type { TeamMember } from '../../interfaces/team'; +import Image from 'next/image'; + +interface MemberCardProps { + member: TeamMember; +} + +export default function MemberCard({ member }: MemberCardProps) { + return ( +
+ +
+ {member.name} + {member.email} +
+
+ ); +} 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..07f8162 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Member/MemberSection.module.css @@ -0,0 +1,48 @@ +.section { + background: var(--color-background-primary); + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.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; +} 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..7cf8e18 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Member/MemberSection.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useState } from 'react'; +import MemberInvite from '@/components/Modal/domain/components/MemberInvite/MemberInvite'; +import MemberCard from './MemberCard'; +import styles from './MemberSection.module.css'; +import { MOCK_INVITE_LINK } from '../../constants/mockData'; +import type { TeamMember } from '../../interfaces/team'; + +interface MemberSectionProps { + members: TeamMember[]; + isAdmin: boolean; + teamId: string; +} + +export default function MemberSection({ members, isAdmin, teamId }: MemberSectionProps) { + const [isInviteOpen, setIsInviteOpen] = useState(false); + + const handleCopyLink = (link: string) => { + navigator.clipboard.writeText(link).catch(() => {}); + }; + + return ( +
+
+

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

+ +
+ +
+ {members.map((member) => ( + + ))} +
+ + setIsInviteOpen(false)} + invite={{ + link: MOCK_INVITE_LINK, + onCopyLink: handleCopyLink, + }} + /> +
+ ); +} From 21dd60ae574c342e5a3372f92c0094ada8e5d205 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 20 Feb 2026 02:48:36 +0900 Subject: [PATCH 04/39] =?UTF-8?q?feat:=20=ED=8C=80=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Team/TeamDashboard.module.css | 43 ++++++ .../_domain/components/Team/TeamDashboard.tsx | 54 ++++++++ .../Team/TeamSidebarDropdown.module.css | 129 ++++++++++++++++++ .../components/Team/TeamSidebarDropdown.tsx | 74 ++++++++++ 4 files changed, 300 insertions(+) create mode 100644 src/app/[teamid]/_domain/components/Team/TeamDashboard.module.css create mode 100644 src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx create mode 100644 src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.module.css create mode 100644 src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx 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..5ea18d3 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamDashboard.module.css @@ -0,0 +1,43 @@ +.container { + display: flex; + flex-direction: column; + padding: 24px; + gap: 24px; + min-height: 100%; +} + +.content { + display: flex; + gap: 24px; + align-items: flex-start; +} + +.kanbanArea { + flex: 1; + min-width: 0; +} + +.rightPanel { + width: 240px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +@media (max-width: 1280px) { + .content { + flex-direction: 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..988cdf0 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useState } from 'react'; +import { useParams } from 'next/navigation'; +import TeamHeader from '@/components/team-header/TeamHeader'; +import KanbanBoard from '../Kanban/KanbanBoard'; +import TodayReport from '../TodayReport/TodayReport'; +import MemberSection from '../Member/MemberSection'; +import styles from './TeamDashboard.module.css'; +import { + MOCK_MEMBERS, + MOCK_TASKS, + MOCK_TODAY_REPORT, + MOCK_TEAM_NAME, +} from '../../constants/mockData'; +import type { KanbanTask } from '../../interfaces/team'; + +const isAdmin = true; + +export default function TeamDashboard() { + const params = useParams<{ teamid: string }>(); + const teamid = params?.teamid ?? '1'; + + const [tasks, setTasks] = useState(MOCK_TASKS); + + const memberImageUrls = MOCK_MEMBERS.filter((m) => m.imageUrl).map((m) => m.imageUrl as string); + + return ( +
+ + + + +
+
+ +
+ + +
+
+ ); +} 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..07ad833 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.module.css @@ -0,0 +1,129 @@ +.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); +} + +.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..58e68b3 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx @@ -0,0 +1,74 @@ +'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 { MOCK_TEAMS } from '../../constants/mockData'; + +export default function TeamSidebarDropdown() { + const params = useParams<{ teamid: string }>(); + const teamid = params?.teamid ?? ''; + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ {/* 팀 선택 섹션 */} +
+ + + {isOpen && ( + <> + {MOCK_TEAMS.map((team) => ( + + + {team.name} + + ))} + + + + )} +
+ + {/* 구분선 */} +
+ + {/* 자유게시판 */} + + + 자유게시판 + +
+ ); +} From 9177b5bc8260765fd3b2374ec9dd1ac82d4f23df Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 20 Feb 2026 02:49:06 +0900 Subject: [PATCH 05/39] =?UTF-8?q?feat:=20=EC=A7=84=ED=96=89=EC=83=81?= =?UTF-8?q?=ED=99=A9=20=EC=9A=94=EC=95=BD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TodayReport/TodayReport.module.css | 58 +++++++++++++++ .../components/TodayReport/TodayReport.tsx | 36 ++++++++++ .../[teamid]/_domain/constants/mockData.ts | 70 +++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 src/app/[teamid]/_domain/components/TodayReport/TodayReport.module.css create mode 100644 src/app/[teamid]/_domain/components/TodayReport/TodayReport.tsx create mode 100644 src/app/[teamid]/_domain/constants/mockData.ts 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..9e2f93b --- /dev/null +++ b/src/app/[teamid]/_domain/components/TodayReport/TodayReport.module.css @@ -0,0 +1,58 @@ +.card { + width: 1120px; + background: var(--color-background-primary); + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.title { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + margin: 0; +} + +.percentRow { + display: flex; + align-items: baseline; + gap: 4px; +} + +.percent { + font-size: 40px; + font-weight: 700; + color: var(--color-brand-primary); + line-height: 1; +} + +.stats { + display: flex; + gap: 16px; + margin-top: 4px; +} + +.statItem { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + padding: 12px; + background: var(--color-background-secondary); + border-radius: 12px; +} + +.statLabel { + font-size: 12px; + font-weight: 500; + color: var(--color-text-default); +} + +.statValue { + font-size: 24px; + font-weight: 700; + color: var(--color-text-primary); + line-height: 1.2; +} 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..a80604e --- /dev/null +++ b/src/app/[teamid]/_domain/components/TodayReport/TodayReport.tsx @@ -0,0 +1,36 @@ +'use client'; + +import ProgressBar from '@/components/progressbar/ProgressBar'; +import styles from './TodayReport.module.css'; + +interface TodayReportProps { + totalTasks: number; + doneTasks: number; +} + +export default function TodayReport({ totalTasks, doneTasks }: TodayReportProps) { + const progressPercent = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0; + + return ( +
+

오늘의 진행 상황

+ +
+ {progressPercent}% +
+ + + +
+
+ 오늘의 할 일 + {totalTasks} +
+
+ 완료 🙌 + {doneTasks} +
+
+
+ ); +} 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'; From 6f31e3f1fb4958c68dd608760ab82fb6bc61ca6f Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 20 Feb 2026 02:49:30 +0900 Subject: [PATCH 06/39] =?UTF-8?q?chore:=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[teamid]/_domain/interfaces/team.ts | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/app/[teamid]/_domain/interfaces/team.ts 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; +} From 9b775434792bbf07d64f54755c453a3d4d857183 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 21 Feb 2026 14:44:20 +0900 Subject: [PATCH 07/39] =?UTF-8?q?feat:=20=ED=8C=80=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_domain/components/Kanban/KanbanBoard.tsx | 32 +++-- .../components/Kanban/KanbanColumn.module.css | 26 ++-- .../components/Kanban/KanbanColumn.tsx | 20 ++- .../components/Kanban/KanbanItem.module.css | 51 ++++--- .../_domain/components/Kanban/KanbanItem.tsx | 106 +++++++++----- .../components/Member/MemberCard.module.css | 5 + .../_domain/components/Member/MemberCard.tsx | 4 + .../Member/MemberSection.module.css | 1 + .../components/Team/TeamDashboard.module.css | 7 + .../_domain/components/Team/TeamDashboard.tsx | 15 +- .../components/Team/TeamSidebarDropdown.tsx | 19 ++- .../Team/_modals/CreateTeamModal.constants.ts | 5 + .../Team/_modals/CreateTeamModal.module.css | 130 ++++++++++++++++++ .../Team/_modals/CreateTeamModal.tsx | 86 ++++++++++++ .../Team/_modals/CreateTeamModal.types.ts | 18 +++ .../TodayReport/TodayReport.module.css | 93 ++++++++++--- .../components/TodayReport/TodayReport.tsx | 58 ++++++-- 17 files changed, 548 insertions(+), 128 deletions(-) create mode 100644 src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.constants.ts create mode 100644 src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.module.css create mode 100644 src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.tsx create mode 100644 src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.types.ts diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx index df8e019..8970fe7 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx @@ -16,7 +16,7 @@ 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 { KanbanTask, KanbanStatus } from '../interfaces/team'; +import type { KanbanTask, KanbanStatus } from '../../interfaces/team'; const KANBAN_COLUMNS: { id: KanbanStatus; label: string }[] = [ { id: 'todo', label: '할 일' }, @@ -33,7 +33,7 @@ interface KanbanBoardProps { export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProps) { const router = useRouter(); const [activeTask, setActiveTask] = useState(null); - const [isAddListOpen, setIsAddListOpen] = useState(false); + const [addingStatus, setAddingStatus] = useState(null); const [newListTitle, setNewListTitle] = useState(''); const sensors = useSensors( @@ -110,19 +110,27 @@ export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProp router.push(`/${teamId}/tasks/${taskId}`); }; + const handleDeleteTask = (taskId: string) => { + setTasks((prev) => prev.filter((t) => t.id !== taskId)); + }; + + const handleEditTask = (_taskId: string) => { + // 수정 기능 추후 구현 + }; + const handleAddListSubmit = () => { - if (!newListTitle.trim()) return; + if (!newListTitle.trim() || !addingStatus) return; const newTask: KanbanTask = { id: `task-${Date.now()}`, title: newListTitle.trim(), - status: 'todo', + status: addingStatus, items: [], }; setTasks((prev) => [...prev, newTask]); setNewListTitle(''); - setIsAddListOpen(false); + setAddingStatus(null); }; return ( @@ -142,6 +150,9 @@ export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProp tasks={getTasksByStatus(col.id)} onItemCheckedChange={handleItemCheckedChange} onCardClick={handleCardClick} + onAddTask={(status) => setAddingStatus(status)} + onDeleteTask={handleDeleteTask} + onEditTask={handleEditTask} /> ))} @@ -155,13 +166,12 @@ export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProp - - setIsAddListOpen(false)} + isOpen={addingStatus !== null} + onClose={() => { + setAddingStatus(null); + setNewListTitle(''); + }} onSubmit={handleAddListSubmit} input={{ props: { diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css index 6e81950..5924ce9 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css @@ -1,5 +1,5 @@ .column { - min-width: 280px; + min-width: 270px; flex-shrink: 0; display: flex; flex-direction: column; @@ -9,8 +9,8 @@ .columnHeader { display: flex; align-items: center; - gap: 8px; - padding: 4px 0; + justify-content: space-between; + padding: 0 12px 0 24px; background-color: var(--color-background-tertiary); border-radius: 12px; height: 38px; @@ -20,21 +20,17 @@ font-size: 16px; font-weight: 600; color: var(--color-text-primary); - margin-left: 24px; } -.columnCount { +.addButton { display: flex; - align-items: center; - justify-content: center; - min-width: 20px; - height: 20px; - padding: 0 6px; - border-radius: 10px; - background: var(--color-background-tertiary); - font-size: 12px; - font-weight: 600; - color: var(--color-text-default); + width: 24px; + height: 24px; + padding: 0; + border: none; + background: none; + cursor: pointer; + flex-shrink: 0; } .itemList { diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx index 62aad59..1ed3770 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx @@ -2,9 +2,12 @@ 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 } from '../../interfaces/team'; +import Image from 'next/image'; +import Plus from '@/assets/buttons/plus/plusBoxButton.svg'; const COLUMN_LABELS: Record = { todo: '할 일', @@ -17,6 +20,9 @@ interface KanbanColumnProps { 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; } export default function KanbanColumn({ @@ -24,6 +30,9 @@ export default function KanbanColumn({ tasks, onItemCheckedChange, onCardClick, + onAddTask, + onDeleteTask, + onEditTask, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id: status }); const itemIds = tasks.map((t) => t.id); @@ -32,7 +41,14 @@ export default function KanbanColumn({

{COLUMN_LABELS[status]}

- {tasks.length} +
@@ -43,6 +59,8 @@ export default function KanbanColumn({ task={task} onItemCheckedChange={onItemCheckedChange} onCardClick={onCardClick} + onDeleteTask={onDeleteTask} + onEditTask={onEditTask} /> ))}
diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css index b0d87e4..269ae10 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css @@ -3,34 +3,45 @@ display: flex; flex-direction: column; cursor: pointer; + width: 270px; } -.dragHandle { - display: flex; - align-items: center; - justify-content: center; - height: 20px; - cursor: grab; - color: var(--color-interaction-inactive); - font-size: 16px; - user-select: none; - margin-bottom: 4px; - opacity: 0; - transition: opacity 0.15s; +.cardWrapper { + position: relative; } -.item:hover .dragHandle { - opacity: 1; +.todoCard { + flex: 1; } -.dragHandle:active { - cursor: grabbing; +.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; } -.dragDots { - letter-spacing: 2px; +.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; } -.todoCard { - flex: 1; +.menuItem:hover { + background-color: var(--color-background-tertiary); } diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx index 6142f1f..cffe4cc 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx @@ -1,27 +1,33 @@ 'use client'; +import { 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 } from '../interfaces/team'; +import type { KanbanTask } 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; } -export default function KanbanItem({ task, onItemCheckedChange, onCardClick }: KanbanItemProps) { - const { - attributes, - listeners, - setNodeRef, - setActivatorNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: task.id }); +export default function KanbanItem({ + task, + onItemCheckedChange, + onCardClick, + onDeleteTask, + onEditTask, +}: KanbanItemProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const containerRef = useRef(null); + + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: task.id, + }); const style = { transform: CSS.Transform.toString(transform), @@ -29,6 +35,18 @@ export default function KanbanItem({ task, onItemCheckedChange, onCardClick }: K 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) => { const target = e.target as HTMLElement; if (!target.closest('button, input, label, a')) { @@ -36,29 +54,53 @@ export default function KanbanItem({ task, onItemCheckedChange, onCardClick }: K } }; + const handleKebabClick = () => { + setIsMenuOpen((prev) => !prev); + }; + + const handleEdit = () => { + setIsMenuOpen(false); + onEditTask?.(task.id); + }; + + const handleDelete = () => { + setIsMenuOpen(false); + onDeleteTask?.(task.id); + }; + return ( -
-
- + // dnd-kit의 PointerSensor는 button 요소를 드래그 제외 대상으로 처리하므로 + // listeners를 item 컨테이너에 배치하여 카드 전체 영역에서 드래그 가능하게 함 +
+
+ onItemCheckedChange(task.id, itemId, checked) + : undefined + } + onKebabClick={handleKebabClick} + className={styles.todoCard} + /> + {isMenuOpen && ( +
+ + +
+ )}
- onItemCheckedChange(task.id, itemId, checked) - : undefined - } - className={styles.todoCard} - />
); } diff --git a/src/app/[teamid]/_domain/components/Member/MemberCard.module.css b/src/app/[teamid]/_domain/components/Member/MemberCard.module.css index 02d5f30..1d72e46 100644 --- a/src/app/[teamid]/_domain/components/Member/MemberCard.module.css +++ b/src/app/[teamid]/_domain/components/Member/MemberCard.module.css @@ -11,6 +11,11 @@ padding-bottom: 0; } +.kebab { + margin-left: auto; + flex-shrink: 0; +} + .avatar { width: 32px; height: 32px; diff --git a/src/app/[teamid]/_domain/components/Member/MemberCard.tsx b/src/app/[teamid]/_domain/components/Member/MemberCard.tsx index 7a50184..631b34b 100644 --- a/src/app/[teamid]/_domain/components/Member/MemberCard.tsx +++ b/src/app/[teamid]/_domain/components/Member/MemberCard.tsx @@ -1,3 +1,4 @@ +import KebabMenu from '@/components/KebabMenu/KebabMenu'; import styles from './MemberCard.module.css'; import type { TeamMember } from '../../interfaces/team'; import Image from 'next/image'; @@ -20,6 +21,9 @@ export default function MemberCard({ member }: MemberCardProps) { {member.name} {member.email}
+
+ {}} onDelete={() => {}} /> +
); } diff --git a/src/app/[teamid]/_domain/components/Member/MemberSection.module.css b/src/app/[teamid]/_domain/components/Member/MemberSection.module.css index 07f8162..b7c194b 100644 --- a/src/app/[teamid]/_domain/components/Member/MemberSection.module.css +++ b/src/app/[teamid]/_domain/components/Member/MemberSection.module.css @@ -5,6 +5,7 @@ display: flex; flex-direction: column; gap: 12px; + margin-top: 44px; } .header { diff --git a/src/app/[teamid]/_domain/components/Team/TeamDashboard.module.css b/src/app/[teamid]/_domain/components/Team/TeamDashboard.module.css index 5ea18d3..2f5d6e1 100644 --- a/src/app/[teamid]/_domain/components/Team/TeamDashboard.module.css +++ b/src/app/[teamid]/_domain/components/Team/TeamDashboard.module.css @@ -1,4 +1,5 @@ .container { + width: 1240px; display: flex; flex-direction: column; padding: 24px; @@ -12,6 +13,12 @@ align-items: flex-start; } +.divider { + width: 100%; + height: 1px; + background: var(--Border-Primary, #e2e8f0); +} + .kanbanArea { flex: 1; min-width: 0; diff --git a/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx b/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx index 988cdf0..76ce9de 100644 --- a/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx +++ b/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useParams } from 'next/navigation'; -import TeamHeader from '@/components/team-header/TeamHeader'; import KanbanBoard from '../Kanban/KanbanBoard'; import TodayReport from '../TodayReport/TodayReport'; import MemberSection from '../Member/MemberSection'; @@ -23,23 +22,17 @@ export default function TeamDashboard() { const [tasks, setTasks] = useState(MOCK_TASKS); - const memberImageUrls = MOCK_MEMBERS.filter((m) => m.imageUrl).map((m) => m.imageUrl as string); - return (
- - +
+
diff --git a/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx index 58e68b3..bff6af7 100644 --- a/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx +++ b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx @@ -11,11 +11,21 @@ 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 { MOCK_TEAMS } from '../../constants/mockData'; +import CreateTeamModal from './Modals/CreateTeamModal'; // CreateTeamModal 컴포넌트 임포트 export default function TeamSidebarDropdown() { const params = useParams<{ teamid: string }>(); const teamid = params?.teamid ?? ''; const [isOpen, setIsOpen] = useState(true); + const [isCreateTeamModalOpen, setIsCreateTeamModalOpen] = useState(false); // 팀 생성 모달 상태 + + const handleOpenCreateTeamModal = () => { + setIsCreateTeamModalOpen(true); + }; + + const handleCloseCreateTeamModal = () => { + setIsCreateTeamModalOpen(false); + }; return (
@@ -53,7 +63,11 @@ export default function TeamSidebarDropdown() { ))} - @@ -69,6 +83,9 @@ export default function TeamSidebarDropdown() { 자유게시판 + + {/* 팀 생성 모달 */} +
); } diff --git a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.constants.ts b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.constants.ts new file mode 100644 index 0000000..d310e17 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.constants.ts @@ -0,0 +1,5 @@ +export const TITLE_ID = 'create-team-modal-title'; +export const DEFAULT_TITLE = '새로운 팀 생성'; +export const DEFAULT_SUBMIT_LABEL = '팀 생성'; +export const DEFAULT_PLACEHOLDER = '팀 이름을 입력해주세요'; +export const CLOSE_BUTTON_ARIA_LABEL = '팀 생성 모달 닫기'; diff --git a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.module.css b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.module.css new file mode 100644 index 0000000..1b22e70 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.module.css @@ -0,0 +1,130 @@ +section > .modalContent { + width: 384px; /* Adjust as needed for create team form */ + height: 235px; /* Adjust as needed for create team form */ + display: inline-flex; + padding: 16px 16px 32px; + flex-direction: column; + align-items: flex-start; + gap: 10px; + border-radius: 24px; + background: var(--Background-Primary, #fff); + box-sizing: border-box; +} + +.container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + box-sizing: border-box; +} + +.buttonContainer { + display: flex; + align-items: center; + justify-content: flex-end; + margin-right: 8px; +} + +.header { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + gap: 12px; +} + +.title { + margin: 0; + color: var(--Text-Primary, #1e293b); + font-family: Pretendard; + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 19px; +} + +.closeButton { + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.buttonContainer .closeButton, +.buttonContainer .closeButton:hover:not(:disabled), +.buttonContainer .closeButton:active:not(:disabled) { + border: none; + background: transparent; + color: inherit; +} + +.form { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 24px; +} + +.form .input { + display: flex; + width: 280px; + height: 48px; + padding: 16px; + align-items: center; + gap: 10px; + border-radius: 12px; + border: 1px solid var(--Border-Primary, #e2e8f0); + background: var(--Background-Primary, #fff); + box-sizing: border-box; + margin: 0 auto; +} + +.form .input::placeholder { + color: var(--Text-Default, #64748b); + font-family: Pretendard; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 19px; +} + +.footer { + display: flex; + justify-content: center; + margin-top: auto; +} + +.button { + display: flex; + width: 280px; + height: 48px; + justify-content: center; + align-items: center; + gap: 10px; + flex-shrink: 0; + border: none; + border-radius: 12px; + background: var(--Color-Brand-Primary, #5189fa); + color: var(--Text-inverse, #fff); + text-align: center; + font-family: Pretendard; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: 19px; +} + +@media (max-width: 480px) { + section > .modalContent { + border-radius: 24px 24px 0 0; + } +} diff --git a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.tsx b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.tsx new file mode 100644 index 0000000..77d7b1d --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.tsx @@ -0,0 +1,86 @@ +'use client'; + +import Image from 'next/image'; +import type { FormEvent } from 'react'; +import BaseButton from '@/components/Button/base/BaseButton'; +import { Input } from '@/components/input'; +import Modal from '@/components/Modal/Modal'; // Adjust import path if needed +import styles from './CreateTeamModal.module.css'; +import xMarkBig from '@/assets/icons/xMark/xMarkBig.svg'; +import { + CLOSE_BUTTON_ARIA_LABEL, + DEFAULT_PLACEHOLDER, + DEFAULT_SUBMIT_LABEL, + DEFAULT_TITLE, + TITLE_ID, +} from './CreateTeamModal.constants'; +import type { CreateTeamModalProps } from './CreateTeamModal.types'; +export type { CreateTeamModalProps } from './CreateTeamModal.types'; + +/** + * @param props.isOpen 모달 표시 여부를 boolean으로 전달합니다. + * @param props.onClose 모달을 닫을 때 실행할 함수를 전달합니다. + * @param props.onSubmit 팀 생성 버튼 클릭 시 실행할 함수를 전달합니다. + * @param props.text 모달 제목과 버튼 문구 같은 텍스트 옵션을 객체로 전달합니다. + * @param props.input 팀 이름 입력창에 적용할 옵션을 객체로 전달합니다. + */ +export default function CreateTeamModal({ + isOpen, + onClose, + onSubmit, + text, + input, +}: CreateTeamModalProps) { + const title = text?.title ?? DEFAULT_TITLE; + const submitLabel = text?.submitLabel ?? DEFAULT_SUBMIT_LABEL; + const inputPlaceholder = text?.inputPlaceholder ?? DEFAULT_PLACEHOLDER; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + // TODO: 여기에 실제 팀 이름 값을 가져와 onSubmit에 전달하는 로직 추가 + // 현재는 더미값으로 호출하거나, input ref를 사용하거나, state를 관리해야 함 + onSubmit('새로운 팀 이름'); // 임시로 "새로운 팀 이름" 전달 + }; + + return ( + +
+
+ + + +
+
+

+ {title} +

+
+ +
+ +
+ + {submitLabel} + +
+
+
+
+ ); +} diff --git a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.types.ts b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.types.ts new file mode 100644 index 0000000..4a34c58 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.types.ts @@ -0,0 +1,18 @@ +import type { InputHTMLAttributes } from 'react'; +import type { ModalProps } from '@/components/Modal/types/types'; + +export interface CreateTeamModalTextOptions { + title?: string; + submitLabel?: string; + inputPlaceholder?: string; +} + +export interface CreateTeamModalInputOptions { + props?: InputHTMLAttributes; +} + +export interface CreateTeamModalProps extends ModalProps { + onSubmit: (teamName: string) => void; + text?: CreateTeamModalTextOptions; + input?: CreateTeamModalInputOptions; +} diff --git a/src/app/[teamid]/_domain/components/TodayReport/TodayReport.module.css b/src/app/[teamid]/_domain/components/TodayReport/TodayReport.module.css index 9e2f93b..4f1265c 100644 --- a/src/app/[teamid]/_domain/components/TodayReport/TodayReport.module.css +++ b/src/app/[teamid]/_domain/components/TodayReport/TodayReport.module.css @@ -1,58 +1,107 @@ .card { - width: 1120px; + width: 100%; background: var(--color-background-primary); - border-radius: 16px; - padding: 20px; + 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: 12px; + gap: 36px; } -.title { - font-size: 14px; - font-weight: 600; +.teamName { + font-size: 24px; + font-weight: 700; color: var(--color-text-primary); + line-height: 1.167; margin: 0; } -.percentRow { +.content { + display: grid; + grid-template-columns: 1fr auto; + column-gap: 24px; + row-gap: 16px; +} + +.row { + grid-column: 1; display: flex; - align-items: baseline; - gap: 4px; + 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; + line-height: 1.193; } -.stats { +.statsGroup { display: flex; - gap: 16px; - margin-top: 4px; + align-items: center; + gap: 24px; } .statItem { display: flex; flex-direction: column; + align-items: center; gap: 4px; - flex: 1; - padding: 12px; - background: var(--color-background-secondary); - border-radius: 12px; } .statLabel { font-size: 12px; font-weight: 500; - color: var(--color-text-default); + color: var(--color-interaction-inactive); } .statValue { - font-size: 24px; + font-size: 32px; font-weight: 700; - color: var(--color-text-primary); - line-height: 1.2; + 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; +} + +.settingsLink { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + text-decoration: none; } diff --git a/src/app/[teamid]/_domain/components/TodayReport/TodayReport.tsx b/src/app/[teamid]/_domain/components/TodayReport/TodayReport.tsx index a80604e..b40326b 100644 --- a/src/app/[teamid]/_domain/components/TodayReport/TodayReport.tsx +++ b/src/app/[teamid]/_domain/components/TodayReport/TodayReport.tsx @@ -1,34 +1,62 @@ 'use client'; +import Image from 'next/image'; +import Link from 'next/link'; 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; + settingsHref?: string; } -export default function TodayReport({ totalTasks, doneTasks }: TodayReportProps) { +export default function TodayReport({ + teamName, + totalTasks, + doneTasks, + settingsHref, +}: TodayReportProps) { const progressPercent = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0; return ( -
-

오늘의 진행 상황

+
+

{teamName}

-
- {progressPercent}% -
- - +
+
+
+ 오늘의 진행 상황 + {progressPercent}% +
-
-
- 오늘의 할 일 - {totalTasks} +
+
+ 오늘의 할 일 + {totalTasks} +
+
-
- 완료 🙌 - {doneTasks} + +
+ + {settingsHref && ( + + 설정 버튼 + + )}
From 3c626421ef74771a1900f84b6c3d45813b33f43d Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 21 Feb 2026 21:16:48 +0900 Subject: [PATCH 08/39] =?UTF-8?q?feat:=20addteam=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/_domain/apis/image.ts | 1 + src/app/addteam/_domain/queries/useUploadImageMutation.ts | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 src/app/addteam/_domain/apis/image.ts create mode 100644 src/app/addteam/_domain/queries/useUploadImageMutation.ts 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/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, + }); +} From 0579f25705d2b777edb0984f2f4b866f8a0b6ace Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 21 Feb 2026 21:16:57 +0900 Subject: [PATCH 09/39] =?UTF-8?q?feat:=20=ED=8C=80=20=EA=B7=B8=EB=A3=B9/?= =?UTF-8?q?=ED=95=A0=EC=9D=BC=EB=AA=A9=EB=A1=9D=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[teamid]/_domain/apis/group.ts | 39 +++++++++++ src/app/[teamid]/_domain/apis/taskList.ts | 31 +++++++++ src/app/[teamid]/_domain/apis/types.ts | 65 +++++++++++++++++++ src/app/[teamid]/_domain/queries/queryKeys.ts | 14 ++++ .../queries/useCreateTaskListMutation.ts | 15 +++++ .../_domain/queries/useDeleteGroupMutation.ts | 14 ++++ .../queries/useDeleteTaskListMutation.ts | 14 ++++ .../queries/useGroupInvitationQuery.ts | 19 ++++++ .../[teamid]/_domain/queries/useGroupQuery.ts | 14 ++++ .../_domain/queries/useGroupTasksQuery.ts | 14 ++++ .../_domain/queries/useTaskListQuery.ts | 14 ++++ .../_domain/queries/useUpdateGroupMutation.ts | 20 ++++++ 12 files changed, 273 insertions(+) create mode 100644 src/app/[teamid]/_domain/apis/group.ts create mode 100644 src/app/[teamid]/_domain/apis/taskList.ts create mode 100644 src/app/[teamid]/_domain/apis/types.ts create mode 100644 src/app/[teamid]/_domain/queries/queryKeys.ts create mode 100644 src/app/[teamid]/_domain/queries/useCreateTaskListMutation.ts create mode 100644 src/app/[teamid]/_domain/queries/useDeleteGroupMutation.ts create mode 100644 src/app/[teamid]/_domain/queries/useDeleteTaskListMutation.ts create mode 100644 src/app/[teamid]/_domain/queries/useGroupInvitationQuery.ts create mode 100644 src/app/[teamid]/_domain/queries/useGroupQuery.ts create mode 100644 src/app/[teamid]/_domain/queries/useGroupTasksQuery.ts create mode 100644 src/app/[teamid]/_domain/queries/useTaskListQuery.ts create mode 100644 src/app/[teamid]/_domain/queries/useUpdateGroupMutation.ts diff --git a/src/app/[teamid]/_domain/apis/group.ts b/src/app/[teamid]/_domain/apis/group.ts new file mode 100644 index 0000000..58d5291 --- /dev/null +++ b/src/app/[teamid]/_domain/apis/group.ts @@ -0,0 +1,39 @@ +import { requestJson, requestVoid } from '@/shared/apis/groups/http'; +import type { Group, GroupInvitation, 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', + }); +} + +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..e3b4050 --- /dev/null +++ b/src/app/[teamid]/_domain/apis/types.ts @@ -0,0 +1,65 @@ +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; +} + +// swagger 스펙이 {} 이므로 실제 응답 확인 후 수정 필요 +export interface GroupInvitation { + inviteToken: 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..b6aedd9 --- /dev/null +++ b/src/app/[teamid]/_domain/queries/queryKeys.ts @@ -0,0 +1,14 @@ +export const teamGroupKeys = { + all: ['groups'] as const, + details: () => [...teamGroupKeys.all, 'detail'] as const, + // shared/queries/groups/queryKeys.ts의 groupsKeys와 동일한 키 구조로 캐시 공유 + 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..75b66ad --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useCreateTaskListMutation.ts @@ -0,0 +1,15 @@ +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: () => { + // taskLists가 Group 응답에 포함되므로 group 상세를 무효화 + 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..3870c95 --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useDeleteGroupMutation.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteGroup } from '../apis/group'; +import { teamGroupKeys } from './queryKeys'; + +export function useDeleteGroupMutation(groupId: number) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => deleteGroup(groupId), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: teamGroupKeys.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..d08b03a --- /dev/null +++ b/src/app/[teamid]/_domain/queries/useGroupInvitationQuery.ts @@ -0,0 +1,19 @@ +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/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) }); + }, + }); +} From 9c4bbf69af4db959c79458d87ac533166c6443ee Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 21 Feb 2026 21:17:23 +0900 Subject: [PATCH 10/39] =?UTF-8?q?feat:=20=EC=B9=B8=EB=B0=98=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=A0=91=EA=B8=B0/=ED=8E=BC=EC=B9=98?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_domain/components/Kanban/KanbanBoard.tsx | 12 +- .../components/Kanban/KanbanColumn.tsx | 5 +- .../components/Kanban/KanbanItem.module.css | 97 ++++++++++++++++ .../_domain/components/Kanban/KanbanItem.tsx | 106 ++++++++++++++---- 4 files changed, 193 insertions(+), 27 deletions(-) diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx index 8970fe7..6cd7b0a 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx @@ -16,7 +16,7 @@ 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 { KanbanTask, KanbanStatus } from '../../interfaces/team'; +import type { KanbanTask, KanbanStatus, TaskItem } from '../../interfaces/team'; const KANBAN_COLUMNS: { id: KanbanStatus; label: string }[] = [ { id: 'todo', label: '할 일' }, @@ -114,8 +114,12 @@ export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProp setTasks((prev) => prev.filter((t) => t.id !== taskId)); }; - const handleEditTask = (_taskId: string) => { - // 수정 기능 추후 구현 + const handleUpdateTask = (taskId: string, updatedData: { title: string; items: TaskItem[] }) => { + setTasks((prev) => + prev.map((t) => + t.id === taskId ? { ...t, title: updatedData.title, items: updatedData.items } : t, + ), + ); }; const handleAddListSubmit = () => { @@ -152,7 +156,7 @@ export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProp onCardClick={handleCardClick} onAddTask={(status) => setAddingStatus(status)} onDeleteTask={handleDeleteTask} - onEditTask={handleEditTask} + onUpdateTask={handleUpdateTask} /> ))}
diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx index 1ed3770..1b370e9 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx @@ -5,7 +5,7 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import KanbanItem from './KanbanItem'; import styles from './KanbanColumn.module.css'; -import type { KanbanTask, KanbanStatus } from '../../interfaces/team'; +import type { KanbanTask, KanbanStatus, TaskItem } from '../../interfaces/team'; import Image from 'next/image'; import Plus from '@/assets/buttons/plus/plusBoxButton.svg'; @@ -23,6 +23,7 @@ interface KanbanColumnProps { onAddTask?: (status: KanbanStatus) => void; onDeleteTask?: (taskId: string) => void; onEditTask?: (taskId: string) => void; + onUpdateTask?: (taskId: string, updatedData: { title: string; items: TaskItem[] }) => void; } export default function KanbanColumn({ @@ -33,6 +34,7 @@ export default function KanbanColumn({ onAddTask, onDeleteTask, onEditTask, + onUpdateTask, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id: status }); const itemIds = tasks.map((t) => t.id); @@ -61,6 +63,7 @@ export default function KanbanColumn({ onCardClick={onCardClick} onDeleteTask={onDeleteTask} onEditTask={onEditTask} + onUpdateTask={onUpdateTask} /> ))}
diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css index 269ae10..b41d878 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css @@ -12,6 +12,19 @@ .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 { @@ -45,3 +58,87 @@ .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 index cffe4cc..66a767d 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx @@ -5,7 +5,7 @@ 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 } from '../../interfaces/team'; +import type { KanbanTask, TaskItem } from '../../interfaces/team'; interface KanbanItemProps { task: KanbanTask; @@ -13,6 +13,7 @@ interface KanbanItemProps { onCardClick?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; onEditTask?: (taskId: string) => void; + onUpdateTask?: (taskId: string, updatedData: { title: string; items: TaskItem[] }) => void; } export default function KanbanItem({ @@ -21,8 +22,15 @@ export default function KanbanItem({ 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({ @@ -48,9 +56,10 @@ export default function KanbanItem({ }, [isMenuOpen]); const handleContainerClick = (e: React.MouseEvent) => { + if (isEditing) return; const target = e.target as HTMLElement; if (!target.closest('button, input, label, a')) { - onCardClick?.(task.id); + setIsExpanded((prev) => !prev); } }; @@ -60,6 +69,9 @@ export default function KanbanItem({ const handleEdit = () => { setIsMenuOpen(false); + setEditTitle(task.title); + setEditItems(task.items); + setIsEditing(true); onEditTask?.(task.id); }; @@ -68,6 +80,21 @@ export default function KanbanItem({ 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 컨테이너에 배치하여 카드 전체 영역에서 드래그 가능하게 함 @@ -76,29 +103,64 @@ export default function KanbanItem({ style={style} className={styles.item} onClick={handleContainerClick} - {...listeners} + {...(isEditing ? {} : listeners)} >
- onItemCheckedChange(task.id, itemId, checked) - : undefined - } - onKebabClick={handleKebabClick} - className={styles.todoCard} - /> - {isMenuOpen && ( -
- - + {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 && ( +
+ + +
+ )} + )}
From 8149b4a7774ac1b2f129e10720737213be0588c8 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 21 Feb 2026 21:17:32 +0900 Subject: [PATCH 11/39] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EC=BC=80?= =?UTF-8?q?=EB=B0=A5=20=EB=A9=94=EB=89=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A9=A4=EB=B2=84?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_domain/components/Member/MemberCard.tsx | 4 +- .../Member/MemberKebabMenu.module.css | 63 +++++++++++++++++++ .../components/Member/MemberKebabMenu.tsx | 53 ++++++++++++++++ 3 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 src/app/[teamid]/_domain/components/Member/MemberKebabMenu.module.css create mode 100644 src/app/[teamid]/_domain/components/Member/MemberKebabMenu.tsx diff --git a/src/app/[teamid]/_domain/components/Member/MemberCard.tsx b/src/app/[teamid]/_domain/components/Member/MemberCard.tsx index 631b34b..41ff279 100644 --- a/src/app/[teamid]/_domain/components/Member/MemberCard.tsx +++ b/src/app/[teamid]/_domain/components/Member/MemberCard.tsx @@ -1,4 +1,4 @@ -import KebabMenu from '@/components/KebabMenu/KebabMenu'; +import MemberKebabMenu from './MemberKebabMenu'; import styles from './MemberCard.module.css'; import type { TeamMember } from '../../interfaces/team'; import Image from 'next/image'; @@ -22,7 +22,7 @@ export default function MemberCard({ member }: MemberCardProps) { {member.email}
- {}} onDelete={() => {}} /> + {}} />
); 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 && ( +
+ +
+ )} +
+ ); +} From 499830e2e2afaa0493e2f673d620a6fb894a441d Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 21 Feb 2026 21:17:42 +0900 Subject: [PATCH 12/39] =?UTF-8?q?feat:=20=ED=8C=80=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Team/Modals/CreateTeamModal.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/app/[teamid]/_domain/components/Team/Modals/CreateTeamModal.tsx diff --git a/src/app/[teamid]/_domain/components/Team/Modals/CreateTeamModal.tsx b/src/app/[teamid]/_domain/components/Team/Modals/CreateTeamModal.tsx new file mode 100644 index 0000000..32578c0 --- /dev/null +++ b/src/app/[teamid]/_domain/components/Team/Modals/CreateTeamModal.tsx @@ -0,0 +1,22 @@ +// 'use client'; + +// import Modal from '@/components/Modal/Modal'; +// import type { ModalProps } from '@/components/Modal/types/types'; // ModalProps 타입을 가져옴 + +// interface CreateTeamModalProps extends ModalProps { +// // 여기에 팀 생성 모달에 필요한 추가적인 props를 정의할 수 있습니다. +// // 예를 들어, 팀 생성 성공 시 호출될 콜백 등 +// } + +// export default function CreateTeamModal({ isOpen, onClose, ariaLabel, ...props }: CreateTeamModalProps) { +// return ( +// +//
+//

새로운 팀 생성

+//

여기에 팀 생성 폼이 들어갈 예정입니다.

+// {/* 실제 팀 생성 폼 (input, button 등)을 여기에 추가 */} +// +//
+//
+// ); +// } From 18f016f38bae4c622a8f0e5cc0f07f46eac37291 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 21 Feb 2026 21:17:48 +0900 Subject: [PATCH 13/39] =?UTF-8?q?refactor:=20=ED=8C=80=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=EC=9D=98=20=ED=8C=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=A5=BC=20=EB=AA=A8=EB=8B=AC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Team/TeamSidebarDropdown.tsx | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx index bff6af7..914ee30 100644 --- a/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx +++ b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.tsx @@ -11,21 +11,11 @@ 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 { MOCK_TEAMS } from '../../constants/mockData'; -import CreateTeamModal from './Modals/CreateTeamModal'; // CreateTeamModal 컴포넌트 임포트 export default function TeamSidebarDropdown() { const params = useParams<{ teamid: string }>(); const teamid = params?.teamid ?? ''; const [isOpen, setIsOpen] = useState(true); - const [isCreateTeamModalOpen, setIsCreateTeamModalOpen] = useState(false); // 팀 생성 모달 상태 - - const handleOpenCreateTeamModal = () => { - setIsCreateTeamModalOpen(true); - }; - - const handleCloseCreateTeamModal = () => { - setIsCreateTeamModalOpen(false); - }; return (
@@ -63,14 +53,10 @@ export default function TeamSidebarDropdown() { ))} - + )}
@@ -83,9 +69,6 @@ export default function TeamSidebarDropdown() { 자유게시판 - - {/* 팀 생성 모달 */} - ); } From 680939478282f129980b55b28c0b5216071cfff4 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 21 Feb 2026 21:19:13 +0900 Subject: [PATCH 14/39] =?UTF-8?q?chore:=20git=20ignore=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 58ceba1..3c66a94 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,77 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts - -# personal doc -.documents/* +# dev scripts +scripts/refresh-dev-token.mjs +.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 From 8cd3a92ae02250ab6e19bb50e5ffcf1851ae10a1 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 21 Feb 2026 21:23:50 +0900 Subject: [PATCH 15/39] =?UTF-8?q?chore:=20=ED=8C=80=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=EA=B4=80=EB=A0=A8=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 팀 추가 기능이 모달에서 링크 방식으로 변경됨에 따라 사용하지 않는 모달 컴포넌트 파일들을 삭제 Co-Authored-By: Claude Sonnet 4.6 --- .../Team/Modals/CreateTeamModal.tsx | 22 --- .../Team/_modals/CreateTeamModal.constants.ts | 5 - .../Team/_modals/CreateTeamModal.module.css | 130 ------------------ .../Team/_modals/CreateTeamModal.tsx | 86 ------------ .../Team/_modals/CreateTeamModal.types.ts | 18 --- 5 files changed, 261 deletions(-) delete mode 100644 src/app/[teamid]/_domain/components/Team/Modals/CreateTeamModal.tsx delete mode 100644 src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.constants.ts delete mode 100644 src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.module.css delete mode 100644 src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.tsx delete mode 100644 src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.types.ts diff --git a/src/app/[teamid]/_domain/components/Team/Modals/CreateTeamModal.tsx b/src/app/[teamid]/_domain/components/Team/Modals/CreateTeamModal.tsx deleted file mode 100644 index 32578c0..0000000 --- a/src/app/[teamid]/_domain/components/Team/Modals/CreateTeamModal.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// 'use client'; - -// import Modal from '@/components/Modal/Modal'; -// import type { ModalProps } from '@/components/Modal/types/types'; // ModalProps 타입을 가져옴 - -// interface CreateTeamModalProps extends ModalProps { -// // 여기에 팀 생성 모달에 필요한 추가적인 props를 정의할 수 있습니다. -// // 예를 들어, 팀 생성 성공 시 호출될 콜백 등 -// } - -// export default function CreateTeamModal({ isOpen, onClose, ariaLabel, ...props }: CreateTeamModalProps) { -// return ( -// -//
-//

새로운 팀 생성

-//

여기에 팀 생성 폼이 들어갈 예정입니다.

-// {/* 실제 팀 생성 폼 (input, button 등)을 여기에 추가 */} -// -//
-//
-// ); -// } diff --git a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.constants.ts b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.constants.ts deleted file mode 100644 index d310e17..0000000 --- a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const TITLE_ID = 'create-team-modal-title'; -export const DEFAULT_TITLE = '새로운 팀 생성'; -export const DEFAULT_SUBMIT_LABEL = '팀 생성'; -export const DEFAULT_PLACEHOLDER = '팀 이름을 입력해주세요'; -export const CLOSE_BUTTON_ARIA_LABEL = '팀 생성 모달 닫기'; diff --git a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.module.css b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.module.css deleted file mode 100644 index 1b22e70..0000000 --- a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.module.css +++ /dev/null @@ -1,130 +0,0 @@ -section > .modalContent { - width: 384px; /* Adjust as needed for create team form */ - height: 235px; /* Adjust as needed for create team form */ - display: inline-flex; - padding: 16px 16px 32px; - flex-direction: column; - align-items: flex-start; - gap: 10px; - border-radius: 24px; - background: var(--Background-Primary, #fff); - box-sizing: border-box; -} - -.container { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 10px; - box-sizing: border-box; -} - -.buttonContainer { - display: flex; - align-items: center; - justify-content: flex-end; - margin-right: 8px; -} - -.header { - display: flex; - width: 100%; - align-items: center; - justify-content: center; - gap: 12px; -} - -.title { - margin: 0; - color: var(--Text-Primary, #1e293b); - font-family: Pretendard; - font-size: 16px; - font-style: normal; - font-weight: 500; - line-height: 19px; -} - -.closeButton { - width: 24px; - height: 24px; - padding: 0; - border: none; - background: transparent; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.buttonContainer .closeButton, -.buttonContainer .closeButton:hover:not(:disabled), -.buttonContainer .closeButton:active:not(:disabled) { - border: none; - background: transparent; - color: inherit; -} - -.form { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; - gap: 24px; -} - -.form .input { - display: flex; - width: 280px; - height: 48px; - padding: 16px; - align-items: center; - gap: 10px; - border-radius: 12px; - border: 1px solid var(--Border-Primary, #e2e8f0); - background: var(--Background-Primary, #fff); - box-sizing: border-box; - margin: 0 auto; -} - -.form .input::placeholder { - color: var(--Text-Default, #64748b); - font-family: Pretendard; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: 19px; -} - -.footer { - display: flex; - justify-content: center; - margin-top: auto; -} - -.button { - display: flex; - width: 280px; - height: 48px; - justify-content: center; - align-items: center; - gap: 10px; - flex-shrink: 0; - border: none; - border-radius: 12px; - background: var(--Color-Brand-Primary, #5189fa); - color: var(--Text-inverse, #fff); - text-align: center; - font-family: Pretendard; - font-size: 16px; - font-style: normal; - font-weight: 600; - line-height: 19px; -} - -@media (max-width: 480px) { - section > .modalContent { - border-radius: 24px 24px 0 0; - } -} diff --git a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.tsx b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.tsx deleted file mode 100644 index 77d7b1d..0000000 --- a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.tsx +++ /dev/null @@ -1,86 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import type { FormEvent } from 'react'; -import BaseButton from '@/components/Button/base/BaseButton'; -import { Input } from '@/components/input'; -import Modal from '@/components/Modal/Modal'; // Adjust import path if needed -import styles from './CreateTeamModal.module.css'; -import xMarkBig from '@/assets/icons/xMark/xMarkBig.svg'; -import { - CLOSE_BUTTON_ARIA_LABEL, - DEFAULT_PLACEHOLDER, - DEFAULT_SUBMIT_LABEL, - DEFAULT_TITLE, - TITLE_ID, -} from './CreateTeamModal.constants'; -import type { CreateTeamModalProps } from './CreateTeamModal.types'; -export type { CreateTeamModalProps } from './CreateTeamModal.types'; - -/** - * @param props.isOpen 모달 표시 여부를 boolean으로 전달합니다. - * @param props.onClose 모달을 닫을 때 실행할 함수를 전달합니다. - * @param props.onSubmit 팀 생성 버튼 클릭 시 실행할 함수를 전달합니다. - * @param props.text 모달 제목과 버튼 문구 같은 텍스트 옵션을 객체로 전달합니다. - * @param props.input 팀 이름 입력창에 적용할 옵션을 객체로 전달합니다. - */ -export default function CreateTeamModal({ - isOpen, - onClose, - onSubmit, - text, - input, -}: CreateTeamModalProps) { - const title = text?.title ?? DEFAULT_TITLE; - const submitLabel = text?.submitLabel ?? DEFAULT_SUBMIT_LABEL; - const inputPlaceholder = text?.inputPlaceholder ?? DEFAULT_PLACEHOLDER; - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - // TODO: 여기에 실제 팀 이름 값을 가져와 onSubmit에 전달하는 로직 추가 - // 현재는 더미값으로 호출하거나, input ref를 사용하거나, state를 관리해야 함 - onSubmit('새로운 팀 이름'); // 임시로 "새로운 팀 이름" 전달 - }; - - return ( - -
-
- - - -
-
-

- {title} -

-
- -
- -
- - {submitLabel} - -
-
-
-
- ); -} diff --git a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.types.ts b/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.types.ts deleted file mode 100644 index 4a34c58..0000000 --- a/src/app/[teamid]/_domain/components/Team/_modals/CreateTeamModal.types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { InputHTMLAttributes } from 'react'; -import type { ModalProps } from '@/components/Modal/types/types'; - -export interface CreateTeamModalTextOptions { - title?: string; - submitLabel?: string; - inputPlaceholder?: string; -} - -export interface CreateTeamModalInputOptions { - props?: InputHTMLAttributes; -} - -export interface CreateTeamModalProps extends ModalProps { - onSubmit: (teamName: string) => void; - text?: CreateTeamModalTextOptions; - input?: CreateTeamModalInputOptions; -} From 6f11c8e2ba09c98fd1fcd168a8c474b94d60a4da Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 21 Feb 2026 21:25:31 +0900 Subject: [PATCH 16/39] =?UTF-8?q?style:=20=EB=B2=84=ED=8A=BC=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=91=EC=A4=84=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_domain/components/Team/TeamSidebarDropdown.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.module.css b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.module.css index 07ad833..0955fce 100644 --- a/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.module.css +++ b/src/app/[teamid]/_domain/components/Team/TeamSidebarDropdown.module.css @@ -93,6 +93,7 @@ font-size: 14px; line-height: 1.2143; color: var(--color-brand-primary); + text-decoration: none; } .addIcon { From 6b11e3d8a2756856e238e27e21a46b9d02ffc212 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sun, 22 Feb 2026 15:24:50 +0900 Subject: [PATCH 17/39] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_domain/components/Kanban/KanbanBoard.tsx | 99 ++++++++++--------- .../components/Kanban/KanbanColumn.tsx | 17 ++-- .../_domain/components/Kanban/KanbanItem.tsx | 6 +- 3 files changed, 65 insertions(+), 57 deletions(-) diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx index 6cd7b0a..1d5605a 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx @@ -17,21 +17,21 @@ import AddTodoList from '@/components/Modal/domain/components/AddTodoList/AddTod import KanbanColumn from './KanbanColumn'; import styles from './KanbanBoard.module.css'; import type { KanbanTask, KanbanStatus, TaskItem } from '../../interfaces/team'; +import { MOCK_TASKS } from '../../constants/mockData'; -const KANBAN_COLUMNS: { id: KanbanStatus; label: string }[] = [ +export const KANBAN_COLUMNS: { id: KanbanStatus; label: string }[] = [ { id: 'todo', label: '할 일' }, { id: 'inProgress', label: '진행중' }, { id: 'done', label: '완료' }, ]; interface KanbanBoardProps { - tasks: KanbanTask[]; - setTasks: React.Dispatch>; teamId: string; } -export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProps) { +export default function KanbanBoard({ teamId }: KanbanBoardProps) { const router = useRouter(); + const [tasks, setTasks] = useState(MOCK_TASKS); const [activeTask, setActiveTask] = useState(null); const [addingStatus, setAddingStatus] = useState(null); const [newListTitle, setNewListTitle] = useState(''); @@ -42,14 +42,8 @@ export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProp }), ); - const getTasksByStatus = useCallback( - (status: KanbanStatus) => tasks.filter((t) => t.status === status), - [tasks], - ); - const handleDragStart = (event: DragStartEvent) => { - const found = tasks.find((t) => t.id === String(event.active.id)); - setActiveTask(found ?? null); + setActiveTask(tasks.find((t) => t.id === String(event.active.id)) ?? null); }; const handleDragEnd = (event: DragEndEvent) => { @@ -71,56 +65,68 @@ export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProp } // over가 다른 태스크인 경우 - const overTask = tasks.find((t) => t.id === overId); - const activeTask = tasks.find((t) => t.id === activeId); - if (!overTask || !activeTask) return; + const overTaskItem = tasks.find((t) => t.id === overId); + const activeTaskItem = tasks.find((t) => t.id === activeId); + if (!overTaskItem || !activeTaskItem) return; - if (activeTask.status === overTask.status) { + if (activeTaskItem.status === overTaskItem.status) { // 같은 컬럼 내 순서 변경 setTasks((prev) => { - const colTasks = prev.filter((t) => t.status === activeTask.status); - const otherTasks = prev.filter((t) => t.status !== activeTask.status); + 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); - const reordered = arrayMove(colTasks, oldIdx, newIdx); - return [...otherTasks, ...reordered]; + return [...otherTasks, ...arrayMove(colTasks, oldIdx, newIdx)]; }); } else { // 다른 컬럼으로 이동 setTasks((prev) => - prev.map((t) => (t.id === activeId ? { ...t, status: overTask.status } : t)), + prev.map((t) => (t.id === activeId ? { ...t, status: overTaskItem.status } : t)), ); } }; - const handleItemCheckedChange = (taskId: string, itemId: string, checked: boolean) => { - setTasks((prev) => - prev.map((task) => - task.id === taskId - ? { - ...task, - items: task.items.map((item) => (item.id === itemId ? { ...item, checked } : item)), - } - : task, - ), - ); - }; + const handleItemCheckedChange = useCallback( + (taskId: string, itemId: string, checked: boolean) => { + setTasks((prev) => + prev.map((task) => + task.id === taskId + ? { + ...task, + items: task.items.map((item) => (item.id === itemId ? { ...item, checked } : item)), + } + : task, + ), + ); + }, + [], + ); - const handleCardClick = (taskId: string) => { - router.push(`/${teamId}/tasks/${taskId}`); - }; + const handleCardClick = useCallback( + (taskId: string) => { + router.push(`/${teamId}/tasks/${taskId}`); + }, + [router, teamId], + ); - const handleDeleteTask = (taskId: string) => { + const handleDeleteTask = useCallback((taskId: string) => { setTasks((prev) => prev.filter((t) => t.id !== taskId)); - }; + }, []); - const handleUpdateTask = (taskId: string, updatedData: { title: string; items: TaskItem[] }) => { - setTasks((prev) => - prev.map((t) => - t.id === taskId ? { ...t, title: updatedData.title, items: updatedData.items } : t, - ), - ); - }; + const handleUpdateTask = useCallback( + (taskId: string, updatedData: { title: string; items: TaskItem[] }) => { + setTasks((prev) => + prev.map((t) => + t.id === taskId ? { ...t, title: updatedData.title, items: updatedData.items } : t, + ), + ); + }, + [], + ); + + const handleAddTask = useCallback((status: KanbanStatus) => { + setAddingStatus(status); + }, []); const handleAddListSubmit = () => { if (!newListTitle.trim() || !addingStatus) return; @@ -151,10 +157,11 @@ export default function KanbanBoard({ tasks, setTasks, teamId }: KanbanBoardProp t.status === col.id)} onItemCheckedChange={handleItemCheckedChange} onCardClick={handleCardClick} - onAddTask={(status) => setAddingStatus(status)} + onAddTask={handleAddTask} onDeleteTask={handleDeleteTask} onUpdateTask={handleUpdateTask} /> diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx index 1b370e9..3304aa7 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx @@ -1,5 +1,6 @@ 'use client'; +import { memo } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; @@ -9,14 +10,9 @@ import type { KanbanTask, KanbanStatus, TaskItem } from '../../interfaces/team'; import Image from 'next/image'; import Plus from '@/assets/buttons/plus/plusBoxButton.svg'; -const COLUMN_LABELS: Record = { - todo: '할 일', - inProgress: '진행중', - done: '완료', -}; - interface KanbanColumnProps { status: KanbanStatus; + label: string; tasks: KanbanTask[]; onItemCheckedChange?: (taskId: string, itemId: string, checked: boolean) => void; onCardClick?: (taskId: string) => void; @@ -26,8 +22,9 @@ interface KanbanColumnProps { onUpdateTask?: (taskId: string, updatedData: { title: string; items: TaskItem[] }) => void; } -export default function KanbanColumn({ +function KanbanColumn({ status, + label, tasks, onItemCheckedChange, onCardClick, @@ -42,12 +39,12 @@ export default function KanbanColumn({ return (
-

{COLUMN_LABELS[status]}

+

{label}

@@ -71,3 +68,5 @@ export default function KanbanColumn({
); } + +export default memo(KanbanColumn); diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx index 66a767d..123939e 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; +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'; @@ -16,7 +16,7 @@ interface KanbanItemProps { onUpdateTask?: (taskId: string, updatedData: { title: string; items: TaskItem[] }) => void; } -export default function KanbanItem({ +function KanbanItem({ task, onItemCheckedChange, onCardClick, @@ -166,3 +166,5 @@ export default function KanbanItem({
); } + +export default memo(KanbanItem); From 09fd056900f6ced54b39b874656f942e94ef3b76 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sun, 22 Feb 2026 15:25:14 +0900 Subject: [PATCH 18/39] =?UTF-8?q?refactor:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_domain/components/Team/TeamDashboard.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx b/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx index 76ce9de..42ae8cd 100644 --- a/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx +++ b/src/app/[teamid]/_domain/components/Team/TeamDashboard.tsx @@ -1,18 +1,11 @@ 'use client'; -import { useState } from 'react'; import { useParams } from 'next/navigation'; import KanbanBoard from '../Kanban/KanbanBoard'; import TodayReport from '../TodayReport/TodayReport'; import MemberSection from '../Member/MemberSection'; import styles from './TeamDashboard.module.css'; -import { - MOCK_MEMBERS, - MOCK_TASKS, - MOCK_TODAY_REPORT, - MOCK_TEAM_NAME, -} from '../../constants/mockData'; -import type { KanbanTask } from '../../interfaces/team'; +import { MOCK_MEMBERS, MOCK_TODAY_REPORT, MOCK_TEAM_NAME } from '../../constants/mockData'; const isAdmin = true; @@ -20,8 +13,6 @@ export default function TeamDashboard() { const params = useParams<{ teamid: string }>(); const teamid = params?.teamid ?? '1'; - const [tasks, setTasks] = useState(MOCK_TASKS); - return (
- +