Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/app/workspaces/[workspaceId]/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand All @@ -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 (
<DashboardView
key={workspaceId}
workspaceId={workspaceId}
purpose={workspace.purpose}
currentUserId={currentUserId}
initialLayout={initialLayout}
/>
);
Expand Down
2 changes: 1 addition & 1 deletion src/entities/side-project/sprint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
11 changes: 11 additions & 0 deletions src/entities/side-project/sprint/model/sprint.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/entities/task/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
14 changes: 14 additions & 0 deletions src/entities/task/model/task.types.ts
Original file line number Diff line number Diff line change
@@ -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<TaskStatus, StatusStyle> = {
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;
Expand Down
6 changes: 6 additions & 0 deletions src/shared/dashboard/model/widget.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions src/shared/dashboard/ui/widget-state-message.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<WidgetCard>
{header}
<div className="text-brand-muted flex min-h-0 flex-1 items-center justify-center text-center text-sm">
{message}
</div>
</WidgetCard>
);
}
15 changes: 11 additions & 4 deletions src/views/dashboard/config/widget-catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <SprintSummary />,
render: (_size, { workspaceId }) => <SprintSummary workspaceId={workspaceId} />,
},
'my-tasks': {
layout: { i: 'my-tasks', x: 0, y: 4, w: 6, h: 5, minW: 2, minH: 3 },
title: '내 업무',
render: (size) => <MyTasks size={size} />,
render: (size, { workspaceId, purpose, currentUserId }) => (
<MyTasks
workspaceId={workspaceId}
purpose={purpose}
currentUserId={currentUserId}
size={size}
/>
),
},
velocity: {
layout: { i: 'velocity', x: 6, y: 4, w: 6, h: 5, minW: 4, minH: 4 },
title: '벨로시티',
render: () => <Velocity />,
render: (_size, { workspaceId }) => <Velocity workspaceId={workspaceId} />,
},
backlog: {
layout: { i: 'backlog', x: 0, y: 9, w: 6, h: 5, minW: 2, minH: 3 },
title: '백로그',
render: (size) => <Backlog size={size} />,
render: (size, { workspaceId }) => <Backlog workspaceId={workspaceId} size={size} />,
},
'recent-notes': {
layout: { i: 'recent-notes', x: 0, y: 14, w: 6, h: 5, minW: 2, minH: 3 },
Expand Down
9 changes: 8 additions & 1 deletion src/views/dashboard/ui/DashboardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>) => (
Expand All @@ -28,6 +29,10 @@ const renderResizeHandle = (axis: ResizeHandleAxis, ref: Ref<HTMLElement>) => (
interface DashboardGridProps {
/** 위젯 데이터 조회 스코프 */
workspaceId: string;
/** 워크스페이스 용도 — 위젯이 템플릿별로 데이터 소스를 분기할 때 사용 */
purpose: WorkspacePurpose;
/** 현재 로그인 사용자 id — 본인 기준 필터 위젯에 전달 */
currentUserId: string;
/** 위젯 카탈로그 (id → 렌더러) */
widgets: WidgetDefinition[];
layout: Layout;
Expand All @@ -38,6 +43,8 @@ interface DashboardGridProps {

export default function DashboardGrid({
workspaceId,
purpose,
currentUserId,
widgets,
layout,
editMode,
Expand Down Expand Up @@ -107,7 +114,7 @@ export default function DashboardGrid({
</button>
</>
)}
{widget.render(size, { workspaceId })}
{widget.render(size, { workspaceId, purpose, currentUserId })}
</div>
);
})}
Expand Down
5 changes: 5 additions & 0 deletions src/views/dashboard/ui/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ interface DashboardViewProps {
workspaceId: string;
/** 워크스페이스 용도 — 추가 가능한 위젯을 템플릿별로 거른다(레이아웃 조회와는 무관) */
purpose: WorkspacePurpose;
/** 현재 로그인 사용자 id — "내 업무"처럼 본인 기준 필터가 필요한 위젯에 전달 */
currentUserId: string;
/** 서버(RSC)에서 조회한 초기 레이아웃 */
initialLayout: DashboardLayoutState;
/** 향후 페이지별 레이아웃 확장을 위한 구분값 — 현재 DB에는 저장하지 않는다 */
Expand All @@ -35,6 +37,7 @@ interface DashboardViewProps {
export default function DashboardView({
workspaceId,
purpose,
currentUserId,
initialLayout,
pageType = 'dashboard',
}: DashboardViewProps) {
Expand Down Expand Up @@ -62,6 +65,8 @@ export default function DashboardView({
{editMode && <EditModeBanner />}
<DashboardGrid
workspaceId={workspaceId}
purpose={purpose}
currentUserId={currentUserId}
widgets={CATALOG_WIDGETS}
layout={layout}
editMode={editMode}
Expand Down
22 changes: 17 additions & 5 deletions src/widgets/side-project/dashboard-backlog/ui/Backlog.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
'use client';

// 백로그 위젯 — 타일 크기에 따라 밀도가 다른 변형을 렌더
// · sm: 최상위 항목 1건 + 외 N건
// · md/lg: 우선순위 점 + 항목 + 포인트 리스트(넘치면 스크롤)
// 워크스페이스의 백로그(스프린트 미편입) 업무를 셀렉터로 가져온다.
import { currentSprint } from '@/entities/side-project/sprint';
import { getMockBacklogTasks, type Task, TASK_PRIORITY } from '@/entities/side-project/task';
// 워크스페이스의 백로그(스프린트 미편입) 업무를 실데이터로 조회한다.
import { TASK_PRIORITY, useBacklogTasks } from '@/entities/side-project/task';
import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
import { WidgetStateMessage } from '@/shared/dashboard/ui/widget-state-message';

const header = (
<WidgetCardHeader title="백로그" action={<WidgetCardAction>보드</WidgetCardAction>} />
);

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 <WidgetStateMessage header={header} message="백로그를 불러오지 못했습니다." />;
if (isPending) return <WidgetStateMessage header={header} message="백로그를 불러오는 중입니다." />;
if (backlogItems.length === 0)
return <WidgetStateMessage header={header} message="백로그가 비어 있습니다." />;

export default function Backlog({ size = 'md' }: { size?: WidgetSize }) {
if (size === 'sm') {
const [top, ...rest] = backlogItems;
return (
Expand Down
Loading