diff --git a/src/app/workspaces/[workspaceId]/dashboard/page.tsx b/src/app/workspaces/[workspaceId]/dashboard/page.tsx index 3ec3d1f..4cfaa90 100644 --- a/src/app/workspaces/[workspaceId]/dashboard/page.tsx +++ b/src/app/workspaces/[workspaceId]/dashboard/page.tsx @@ -5,6 +5,7 @@ import { getDashboardLayout } from '@/entities/dashboard-layout/api/get-dashboar import { DashboardView } from '@/views/dashboard'; import { notFound } from 'next/navigation'; import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; interface DashboardPageProps { params: Promise<{ workspaceId: string }>; @@ -19,12 +20,16 @@ export default async function DashboardPage({ params }: DashboardPageProps) { notFound(); } - const initialLayout = await getDashboardLayout(workspaceId, 'dashboard'); + const [initialLayout, currentUserId] = await Promise.all([ + getDashboardLayout(workspaceId, 'dashboard'), + getCurrentUserId(), + ]); return ( ); diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts index 2a13f47..f26429a 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -7,7 +7,7 @@ export { sprintsQueryKey, useSprints } from './api/use-sprints'; export { useCreateSprint } from './api/use-create-sprint'; export { useUpdateSprint } from './api/use-update-sprint'; export { useDeleteSprint } from './api/use-delete-sprint'; -export { resolveCurrentSprint, selectVelocity } from './model/sprint.selectors'; +export { resolveCurrentSprint, selectVelocity, selectVelocityMax } from './model/sprint.selectors'; export { toSprint } from './model/sprint.mapper'; export type { SprintRow, SprintRpcRow } from './model/sprint.db.types'; export { sprintInputSchema, type SprintInput } from './model/sprint.schema'; diff --git a/src/entities/side-project/sprint/model/sprint.selectors.ts b/src/entities/side-project/sprint/model/sprint.selectors.ts index 714e3c4..2e1d8aa 100644 --- a/src/entities/side-project/sprint/model/sprint.selectors.ts +++ b/src/entities/side-project/sprint/model/sprint.selectors.ts @@ -15,6 +15,17 @@ export function selectVelocity(sprints: Sprint[]): VelocityPoint[] { })); } +/** + * 벨로시티 차트 Y축 최댓값을 데이터에서 파생한다 — 실 포인트에 맞춰 축을 스케일한다. + * 가장 큰 계획/완료 포인트를 10 단위로 올림하고, 데이터가 없으면 기본 눈금(10)을 쓴다. + * 이렇게 하면 포인트가 상수보다 크면 막대가 잘리고, 작으면 차트가 납작해지는 문제를 막는다. + */ +export function selectVelocityMax(points: VelocityPoint[]): number { + const peak = points.reduce((max, point) => Math.max(max, point.planned, point.completed), 0); + if (peak <= 0) return 10; + return Math.ceil(peak / 10) * 10; +} + export function resolveCurrentSprint(sprints: Sprint[]): Sprint | undefined { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String( diff --git a/src/entities/task/index.ts b/src/entities/task/index.ts index 04a481a..f54f5d3 100644 --- a/src/entities/task/index.ts +++ b/src/entities/task/index.ts @@ -1,4 +1,5 @@ export type { Task, TaskStatus } from './model/task.types'; +export { TASK_STATUS } from './model/task.types'; export type { TaskRow, TaskStatusDb } from './model/task.db.types'; export { toTask, toDbTaskStatus, toUiTaskStatus } from './model/task.mapper'; export { taskTitleSchema, taskBoardItemSchema, updateTaskBoardSchema } from './model/task.schema'; diff --git a/src/entities/task/model/task.types.ts b/src/entities/task/model/task.types.ts index 383a812..40544af 100644 --- a/src/entities/task/model/task.types.ts +++ b/src/entities/task/model/task.types.ts @@ -1,5 +1,19 @@ export type TaskStatus = 'todo' | 'in-progress' | 'done'; +interface StatusStyle { + label: string; + dot: string; + bg: string; + text: string; +} + +// 상태 뱃지 스타일 — 대시보드 등 상태 표시가 필요한 곳에서 공용으로 쓴다. +export const TASK_STATUS: Record = { + todo: { label: '대기', dot: '#d1d5dc', bg: '#f3f4f6', text: '#6a7282' }, + 'in-progress': { label: '진행 중', dot: '#2b7fff', bg: '#e0e7ff', text: '#432dd7' }, + done: { label: '완료', dot: '#22c55e', bg: '#dcfce7', text: '#16a34a' }, +}; + export type Task = { id: string; workspaceId: string; diff --git a/src/shared/dashboard/model/widget.types.ts b/src/shared/dashboard/model/widget.types.ts index 41871a3..6666ae2 100644 --- a/src/shared/dashboard/model/widget.types.ts +++ b/src/shared/dashboard/model/widget.types.ts @@ -4,9 +4,15 @@ import type { ReactNode } from 'react'; import type { LayoutItem } from 'react-grid-layout'; import type { WidgetSize } from '../lib/widget-size'; +import type { WorkspacePurpose } from './template.types'; export interface WidgetRenderContext { + /** 위젯 데이터 조회 스코프 */ workspaceId: string; + /** 워크스페이스 용도 — 같은 위젯이라도 템플릿별로 데이터 소스가 다를 때 분기용 */ + purpose: WorkspacePurpose; + /** 현재 로그인 사용자 id — "내 업무"처럼 본인 기준 필터가 필요한 위젯용 */ + currentUserId: string; } export interface WidgetDefinition { diff --git a/src/shared/dashboard/ui/widget-state-message.tsx b/src/shared/dashboard/ui/widget-state-message.tsx new file mode 100644 index 0000000..97a21f1 --- /dev/null +++ b/src/shared/dashboard/ui/widget-state-message.tsx @@ -0,0 +1,21 @@ +// 위젯 상태 메시지 — 로딩/에러/빈 상태를 카드 안에 중앙 정렬로 보여준다. +// 여러 위젯이 동일한 상태 표시를 쓰므로 공용화한다(헤더는 위젯별로 다르니 prop으로 받는다). +import type { ReactNode } from 'react'; +import { WidgetCard } from './widget-card'; + +interface WidgetStateMessageProps { + /** 위젯별 헤더(제목/액션). 헤더가 필요 없는 위젯은 생략 가능 */ + header?: ReactNode; + message: string; +} + +export function WidgetStateMessage({ header, message }: WidgetStateMessageProps) { + return ( + + {header} + + {message} + + + ); +} diff --git a/src/views/dashboard/config/widget-catalog.tsx b/src/views/dashboard/config/widget-catalog.tsx index fb46c76..0165bae 100644 --- a/src/views/dashboard/config/widget-catalog.tsx +++ b/src/views/dashboard/config/widget-catalog.tsx @@ -23,22 +23,29 @@ export const WIDGET_CATALOG = { 'sprint-summary': { layout: { i: 'sprint-summary', x: 0, y: 0, w: 12, h: 4, minW: 6, minH: 4 }, title: '스프린트 요약', - render: () => , + render: (_size, { workspaceId }) => , }, 'my-tasks': { layout: { i: 'my-tasks', x: 0, y: 4, w: 6, h: 5, minW: 2, minH: 3 }, title: '내 업무', - render: (size) => , + render: (size, { workspaceId, purpose, currentUserId }) => ( + + ), }, velocity: { layout: { i: 'velocity', x: 6, y: 4, w: 6, h: 5, minW: 4, minH: 4 }, title: '벨로시티', - render: () => , + render: (_size, { workspaceId }) => , }, backlog: { layout: { i: 'backlog', x: 0, y: 9, w: 6, h: 5, minW: 2, minH: 3 }, title: '백로그', - render: (size) => , + render: (size, { workspaceId }) => , }, 'recent-notes': { layout: { i: 'recent-notes', x: 0, y: 14, w: 6, h: 5, minW: 2, minH: 3 }, diff --git a/src/views/dashboard/ui/DashboardGrid.tsx b/src/views/dashboard/ui/DashboardGrid.tsx index 270fa43..004299c 100644 --- a/src/views/dashboard/ui/DashboardGrid.tsx +++ b/src/views/dashboard/ui/DashboardGrid.tsx @@ -14,6 +14,7 @@ import { Maximize2, GripVertical, Trash2 } from 'lucide-react'; import { getWidgetSize } from '@/shared/dashboard/lib/widget-size'; import type { WidgetDefinition } from '@/shared/dashboard/model/widget.types'; +import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types'; // 우하단 리사이즈 핸들 커스텀(원형). react-resizable 기본 클래스로 위치를 잡고 배경 삼각형은 제거한다. const renderResizeHandle = (axis: ResizeHandleAxis, ref: Ref) => ( @@ -28,6 +29,10 @@ const renderResizeHandle = (axis: ResizeHandleAxis, ref: Ref) => ( interface DashboardGridProps { /** 위젯 데이터 조회 스코프 */ workspaceId: string; + /** 워크스페이스 용도 — 위젯이 템플릿별로 데이터 소스를 분기할 때 사용 */ + purpose: WorkspacePurpose; + /** 현재 로그인 사용자 id — 본인 기준 필터 위젯에 전달 */ + currentUserId: string; /** 위젯 카탈로그 (id → 렌더러) */ widgets: WidgetDefinition[]; layout: Layout; @@ -38,6 +43,8 @@ interface DashboardGridProps { export default function DashboardGrid({ workspaceId, + purpose, + currentUserId, widgets, layout, editMode, @@ -107,7 +114,7 @@ export default function DashboardGrid({ > )} - {widget.render(size, { workspaceId })} + {widget.render(size, { workspaceId, purpose, currentUserId })} ); })} diff --git a/src/views/dashboard/ui/DashboardView.tsx b/src/views/dashboard/ui/DashboardView.tsx index e527984..20acecf 100644 --- a/src/views/dashboard/ui/DashboardView.tsx +++ b/src/views/dashboard/ui/DashboardView.tsx @@ -26,6 +26,8 @@ interface DashboardViewProps { workspaceId: string; /** 워크스페이스 용도 — 추가 가능한 위젯을 템플릿별로 거른다(레이아웃 조회와는 무관) */ purpose: WorkspacePurpose; + /** 현재 로그인 사용자 id — "내 업무"처럼 본인 기준 필터가 필요한 위젯에 전달 */ + currentUserId: string; /** 서버(RSC)에서 조회한 초기 레이아웃 */ initialLayout: DashboardLayoutState; /** 향후 페이지별 레이아웃 확장을 위한 구분값 — 현재 DB에는 저장하지 않는다 */ @@ -35,6 +37,7 @@ interface DashboardViewProps { export default function DashboardView({ workspaceId, purpose, + currentUserId, initialLayout, pageType = 'dashboard', }: DashboardViewProps) { @@ -62,6 +65,8 @@ export default function DashboardView({ {editMode && } 보드} /> ); -const backlogItems: Task[] = getMockBacklogTasks(currentSprint.workspaceId); +interface BacklogProps { + workspaceId: string; + size?: WidgetSize; +} + +export default function Backlog({ workspaceId, size = 'md' }: BacklogProps) { + const { data: backlogItems, isError, isPending } = useBacklogTasks(workspaceId); + + if (isError) return ; + if (isPending) return ; + if (backlogItems.length === 0) + return ; -export default function Backlog({ size = 'md' }: { size?: WidgetSize }) { if (size === 'sm') { const [top, ...rest] = backlogItems; return ( diff --git a/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx b/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx index b736b3f..7a0e37f 100644 --- a/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx +++ b/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx @@ -1,27 +1,69 @@ -// 내 업무 위젯 — 타일 크기에 따라 밀도가 다른 변형을 렌더 -// · sm: 진행 중 개수 헤드라인 + 대기 건수 요약 -// · md: task 리스트(상태 뱃지) -// · lg: 상태별 카운트 요약 + task 리스트 -// 현재 스프린트에 편입된 업무를 셀렉터로 가져온다(백로그는 애초에 포함되지 않음). -import { currentSprint } from '@/entities/side-project/sprint'; +'use client'; + +// 내 업무 위젯 — 같은 tasks 테이블을 템플릿(purpose)별로 다르게 투영한다. +// · side-project: 현재 스프린트에 편입된 "나에게 배정된" 업무 (포인트/상태) +// · team-project: 워크스페이스의 "나에게 배정된" 보드 업무 (마감일/상태) +// 두 도메인의 Task 모양·status 표기가 달라, 훅 규칙을 지키려 서브컴포넌트로 분기한다 +// (조건부 훅 호출 불가 → purpose로 컴포넌트를 갈라 각자 자기 훅만 호출). +import { resolveCurrentSprint, useSprints } from '@/entities/side-project/sprint'; import { - getMockSprintTasks, - type Task, TASK_STATUS, - type TaskStatus, + useSprintTasks, + type TaskStatus as SprintTaskStatus, } from '@/entities/side-project/task'; +import { + TASK_STATUS as TEAM_TASK_STATUS, + useTasksByWorkspaceId, + type TaskStatus as TeamTaskStatus, +} from '@/entities/task'; import type { WidgetSize } from '@/shared/dashboard/lib/widget-size'; +import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types'; import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card'; +import { WidgetStateMessage } from '@/shared/dashboard/ui/widget-state-message'; const header = ( 전체 보기} /> ); -// 현재 스프린트 편입 업무 -const sprintTasks: Task[] = getMockSprintTasks(currentSprint.id); -const countBy = (status: TaskStatus) => sprintTasks.filter((task) => task.status === status).length; +interface MyTasksProps { + workspaceId: string; + purpose: WorkspacePurpose; + currentUserId: string; + size?: WidgetSize; +} + +export default function MyTasks({ workspaceId, purpose, currentUserId, size = 'md' }: MyTasksProps) { + if (purpose === 'side-project') { + return ; + } + return ; +} + +interface BranchProps { + workspaceId: string; + currentUserId: string; + size: WidgetSize; +} + +// ── side-project: 현재 스프린트 × 나에게 배정된 업무 (포인트/상태) ────────────── +function SideMyTasks({ workspaceId, currentUserId, size }: BranchProps) { + const sprintsQuery = useSprints(workspaceId); + const currentSprint = sprintsQuery.data ? resolveCurrentSprint(sprintsQuery.data) : undefined; + const tasksQuery = useSprintTasks(currentSprint?.id); + + if (sprintsQuery.isError || tasksQuery.isError) { + return ; + } + if (sprintsQuery.isPending) return ; + if (!currentSprint) return ; + if (tasksQuery.isPending) return ; + + const myTasks = tasksQuery.data.filter((task) => task.assigneeId === currentUserId); + if (myTasks.length === 0) return ; + + const countBy = (status: SprintTaskStatus) => + myTasks.filter((task) => task.status === status).length; -export default function MyTasks({ size = 'md' }: { size?: WidgetSize }) { if (size === 'sm') { return ( @@ -36,7 +78,7 @@ export default function MyTasks({ size = 'md' }: { size?: WidgetSize }) { const list = ( - {sprintTasks.map((task) => { + {myTasks.map((task) => { const status = TASK_STATUS[task.status]; return ( @@ -81,7 +123,86 @@ export default function MyTasks({ size = 'md' }: { size?: WidgetSize }) { ); } - // md + return ( + + {header} + {list} + + ); +} + +// ── team-project: 워크스페이스 × 나에게 배정된 보드 업무 (마감일/상태) ────────── +function TeamMyTasks({ workspaceId, currentUserId, size }: BranchProps) { + const { data, isError, isPending } = useTasksByWorkspaceId(workspaceId); + + if (isError) return ; + if (isPending) return ; + + const myTasks = data.filter((task) => task.assigneeId === currentUserId); + if (myTasks.length === 0) return ; + + const countBy = (status: TeamTaskStatus) => + myTasks.filter((task) => task.status === status).length; + + if (size === 'sm') { + return ( + + {header} + + {countBy('in-progress')} + 진행 중 · 대기 {countBy('todo')}건 + + + ); + } + + const list = ( + + {myTasks.map((task) => { + const status = TEAM_TASK_STATUS[task.status]; + return ( + + + {task.title} + {task.dueDate} + + {status.label} + + + ); + })} + + ); + + if (size === 'lg') { + return ( + + {header} + + + 진행 중{' '} + + {countBy('in-progress')} + + + + 대기 {countBy('todo')} + + + 완료 {countBy('done')} + + + {list} + + ); + } + return ( {header} diff --git a/src/widgets/side-project/dashboard-sprint-summary/ui/SprintSummary.tsx b/src/widgets/side-project/dashboard-sprint-summary/ui/SprintSummary.tsx index ec55bd6..bfd7872 100644 --- a/src/widgets/side-project/dashboard-sprint-summary/ui/SprintSummary.tsx +++ b/src/widgets/side-project/dashboard-sprint-summary/ui/SprintSummary.tsx @@ -1,6 +1,9 @@ +'use client'; + // 스프린트 요약 위젯 — 스프린트 배너 + 포인트 통계 3종을 하나의 카드로 조립 -// 통계(계획/완료/남은)와 기간 표시는 currentSprint 메타에서 파생한다. -import { currentSprint } from '@/entities/side-project/sprint'; +// 통계(계획/완료/남은)와 기간 표시는 현재 스프린트 메타에서 파생한다. +// 현재 스프린트는 실데이터 스프린트 목록에서 resolveCurrentSprint로 판정한다. +import { resolveCurrentSprint, useSprints } from '@/entities/side-project/sprint'; import { StatCard, type Stat } from '@/shared/dashboard/ui/stat-card'; // 'YYYY-MM-DD' → 'M/D' @@ -9,8 +12,24 @@ const monthDay = (iso: string) => { return `${Number(month)}/${Number(day)}`; }; -export default function SprintSummary() { - const { name, startDate, endDate, daysLeft, totalPoints, completedPoints } = currentSprint; +function StateMessage({ message }: { message: string }) { + return ( + + {message} + + ); +} + +export default function SprintSummary({ workspaceId }: { workspaceId: string }) { + const { data: sprints, isError, isPending } = useSprints(workspaceId); + + if (isError) return ; + if (isPending) return ; + + const current = resolveCurrentSprint(sprints); + if (!current) return ; + + const { name, startDate, endDate, daysLeft, totalPoints, completedPoints } = current; const period = `${monthDay(startDate)} – ${monthDay(endDate)}`; const planned: Stat = { diff --git a/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx b/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx index 62bed42..e572aec 100644 --- a/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx +++ b/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx @@ -1,19 +1,38 @@ +'use client'; + // 벨로시티 위젯 — 스프린트별 계획/완료 포인트를 막대로 비교 // 막대가 2그룹뿐이라 별도 차트 라이브러리 없이 순수 CSS(div height %)로 구현한다. -import { mockSprints, selectVelocity, VELOCITY_MAX } from '@/entities/side-project/sprint'; +// Y축 최댓값은 실데이터 포인트에서 파생(selectVelocityMax)해 막대 잘림·납작함을 막는다. +import { + selectVelocity, + selectVelocityMax, + useSprints, +} from '@/entities/side-project/sprint'; import { WidgetCard, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card'; +import { WidgetStateMessage } from '@/shared/dashboard/ui/widget-state-message'; + +const header = ; + +export default function Velocity({ workspaceId }: { workspaceId: string }) { + const { data: sprints, isError, isPending } = useSprints(workspaceId); + + if (isError) return ; + if (isPending) return ; + + const sprintVelocity = selectVelocity(sprints); + if (sprintVelocity.length === 0) + return ; -export default function Velocity() { - const sprintVelocity = selectVelocity(mockSprints); + const velocityMax = selectVelocityMax(sprintVelocity); return ( - + {header} {/* Y축 눈금 */} - {VELOCITY_MAX} - {VELOCITY_MAX / 2} + {velocityMax} + {velocityMax / 2} 0 @@ -23,12 +42,12 @@ export default function Velocity() {
{countBy('in-progress')}
진행 중 · 대기 {countBy('todo')}건