setTaskTitle(event.target.value)}
@@ -197,7 +196,7 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) {
setTaskTitle('');
setIsComposerOpen(false);
}}
- className="flex size-10 items-center justify-center rounded-full bg-white text-brand-muted"
+ className="text-brand-muted flex size-10 items-center justify-center rounded-full bg-white"
aria-label="입력 닫기"
>
@@ -216,16 +215,10 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) {
onDragStartTask={handleDragStartTask}
onDragEndTask={handleDragEndTask}
draggingTaskId={draggingTaskId}
- dragOverIndex={
- dragOverState?.columnId === column.id ? dragOverState.index : null
- }
- onDragOverTask={(columnId, index) =>
- setDragOverState({ columnId, index })
- }
+ dragOverIndex={dragOverState?.columnId === column.id ? dragOverState.index : null}
+ onDragOverTask={(columnId, index) => setDragOverState({ columnId, index })}
onDragLeaveColumn={(columnId) => {
- setDragOverState((current) =>
- current?.columnId === columnId ? null : current,
- );
+ setDragOverState((current) => (current?.columnId === columnId ? null : current));
}}
/>
))}
diff --git a/src/shared/dashboard/lib/widget-size.ts b/src/shared/dashboard/lib/widget-size.ts
new file mode 100644
index 0000000..6295236
--- /dev/null
+++ b/src/shared/dashboard/lib/widget-size.ts
@@ -0,0 +1,13 @@
+// 대시보드 위젯 크기 토큰 — 그리드 타일의 폭(w)·높이(h)로 sm/md/lg를 판정한다.
+// 타일을 리사이즈하면 이 값이 바뀌어 위젯이 밀도가 다른 변형을 렌더한다.
+// 템플릿(side-project/store-operation/team-project)에 무관한 대시보드 공용 유틸.
+export type WidgetSize = 'sm' | 'md' | 'lg';
+
+// 폭·높이 각각의 레벨을 구해 "더 작은 쪽"으로 변형을 정한다.
+// → 넓지만 낮은 타일(예: w6·h3)이 lg로 잡혀 내용이 잘리는 문제를 방지한다.
+export function getWidgetSize(w: number, h: number): WidgetSize {
+ const wLevel = w <= 2 ? 0 : w <= 3 ? 1 : 2;
+ const hLevel = h <= 2 ? 0 : h <= 4 ? 1 : 2;
+ const level = Math.min(wLevel, hLevel);
+ return level === 0 ? 'sm' : level === 1 ? 'md' : 'lg';
+}
diff --git a/src/shared/dashboard/model/template.types.ts b/src/shared/dashboard/model/template.types.ts
new file mode 100644
index 0000000..0cd682b
--- /dev/null
+++ b/src/shared/dashboard/model/template.types.ts
@@ -0,0 +1,3 @@
+// 워크스페이스 용도(=대시보드 템플릿) — WORKSPACES.purpose 값과 1:1 대응.
+// 이 값으로 어떤 위젯 구성을 보여줄지 레지스트리에서 선택한다.
+export type WorkspacePurpose = 'side-project' | 'store-operation' | 'team-project';
diff --git a/src/shared/dashboard/model/widget.types.ts b/src/shared/dashboard/model/widget.types.ts
new file mode 100644
index 0000000..3a858d5
--- /dev/null
+++ b/src/shared/dashboard/model/widget.types.ts
@@ -0,0 +1,15 @@
+// 대시보드 위젯 정의 — 렌더(어떻게)와 추가 시 기본 배치(무엇을 어디에)를 한 덩어리로 관리한다.
+// 위젯 id(layout.i)로 저장된 레이아웃(WORKSPACE_LAYOUTS)과 조인된다.
+import type { ReactNode } from 'react';
+import type { LayoutItem } from 'react-grid-layout';
+
+import type { WidgetSize } from '../lib/widget-size';
+
+export interface WidgetDefinition {
+ /** 위젯을 추가할 때의 기본 배치 + 위젯 id(layout.i) */
+ layout: LayoutItem;
+ /** 위젯 추가 목록·라벨 표시명 */
+ title: string;
+ /** 현재 타일 크기(sm/md/lg)를 받아 밀도가 다른 변형을 렌더 */
+ render: (size: WidgetSize) => ReactNode;
+}
diff --git a/src/shared/dashboard/ui/stat-card.tsx b/src/shared/dashboard/ui/stat-card.tsx
new file mode 100644
index 0000000..9f1035b
--- /dev/null
+++ b/src/shared/dashboard/ui/stat-card.tsx
@@ -0,0 +1,25 @@
+// KPI 통계 카드 — 라벨/수치/단위를 표시하는 도메인 무관 표현 컴포넌트
+import { WidgetCard } from './widget-card';
+
+export interface Stat {
+ id: string;
+ label: string;
+ value: number;
+ unit: string;
+ /** 수치 강조 색상 */
+ color: string;
+}
+
+export function StatCard({ stat }: { stat: Stat }) {
+ return (
+
+ {stat.label}
+
+
+ {stat.value}
+
+ {stat.unit}
+
+
+ );
+}
diff --git a/src/shared/dashboard/ui/widget-card.tsx b/src/shared/dashboard/ui/widget-card.tsx
new file mode 100644
index 0000000..2d9d1da
--- /dev/null
+++ b/src/shared/dashboard/ui/widget-card.tsx
@@ -0,0 +1,39 @@
+// 대시보드 위젯 공통 셸 — 흰 카드 컨테이너 + 헤더(제목/액션) 구성 요소
+import * as React from 'react';
+
+import { cn } from '@/shared/lib/utils';
+
+function WidgetCard({ className, children, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ {children}
+
+ );
+}
+
+function WidgetCardHeader({ title, action }: { title: string; action?: React.ReactNode }) {
+ return (
+
+
{title}
+ {action}
+
+ );
+}
+
+function WidgetCardAction({ className, ...props }: React.ComponentProps<'button'>) {
+ return (
+
+ );
+}
+
+export { WidgetCard, WidgetCardHeader, WidgetCardAction };
diff --git a/src/views/dashboard/config/template-widgets.ts b/src/views/dashboard/config/template-widgets.ts
new file mode 100644
index 0000000..d4c2dcf
--- /dev/null
+++ b/src/views/dashboard/config/template-widgets.ts
@@ -0,0 +1,26 @@
+// 템플릿(purpose)별로 대시보드 편집 모드에서 "추가"할 수 있는 위젯 id 목록.
+// WIDGET_CATALOG(전역)의 부분집합이며, 렌더·기본배치는 카탈로그가 담당한다.
+// 값 타입이 WidgetId라 카탈로그에 없는 id를 적으면 컴파일 에러가 난다.
+// 이 목록은 "추가 메뉴 스코프"만 정한다 — 레이아웃 조회/저장(user_id+workspace_id)과는 무관.
+import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types';
+
+import type { WidgetId } from './widget-catalog';
+
+export const TEMPLATE_WIDGETS: Record
= {
+ // 사이드 프로젝트 — 개발 진척 전반
+ 'side-project': [
+ 'sprint-summary',
+ 'my-tasks',
+ 'velocity',
+ 'backlog',
+ 'recent-notes',
+ 'today-schedule',
+ 'calendar',
+ ],
+ // TODO: 매장운영 템플릿에 들어가는 위젯 생성, 추가, 수정
+ // 매장운영 — 일정/업무/캘린더/회의록 (개발 지표 제외)
+ 'store-operation': ['calendar', 'recent-notices', 'recent-resources'],
+ // TODO: 팀플 템플릿에 들어가는 위젯 생성, 추가, 수정
+ // 팀 프로젝트 — 진척·협업
+ 'team-project': ['my-tasks', 'recent-notes', 'calendar', 'recent-notices', 'recent-resources'],
+};
diff --git a/src/views/dashboard/config/widget-catalog.tsx b/src/views/dashboard/config/widget-catalog.tsx
new file mode 100644
index 0000000..c25cb34
--- /dev/null
+++ b/src/views/dashboard/config/widget-catalog.tsx
@@ -0,0 +1,68 @@
+// 대시보드 위젯 카탈로그 — 모든 템플릿에 들어갈 수 있는 위젯을 한 곳에서 관리한다.
+// 각 항목이 렌더러 + 추가 시 기본 배치를 함께 가진다(템플릿별 구분 없음).
+// id를 키로 두어, 여기서 WidgetId를 파생한다 → TEMPLATE_WIDGETS 등이 존재하지 않는 id를 쓰면 컴파일 에러.
+// 대시보드는 빈 상태로 시작하고, 편집 모드에서 이 카탈로그의 위젯을 추가해 구성한다.
+import type { WidgetDefinition } from '@/shared/dashboard/model/widget.types';
+import { Backlog } from '@/widgets/side-project/dashboard-backlog';
+import { Calendar } from '@/widgets/side-project/dashboard-calendar';
+import { MyTasks } from '@/widgets/side-project/dashboard-my-tasks';
+import { RecentNotes } from '@/widgets/side-project/dashboard-recent-notes';
+import { SprintSummary } from '@/widgets/side-project/dashboard-sprint-summary';
+import { TodaySchedule } from '@/widgets/side-project/dashboard-today-schedule';
+import { Velocity } from '@/widgets/side-project/dashboard-velocity';
+import { RecentNotices } from '@/widgets/store-operation/dashboard-recent-notices';
+import { RecentResources } from '@/widgets/store-operation/dashboard-recent-resources';
+
+// layout의 x/y는 "추가될 때의 기본 위치"이며, 그리드가 충돌 시 자동 정렬한다.
+// key는 layout.i(위젯 id)와 일치해야 한다.
+// TODO: 모든 템플릿에 들어가는 위젯을 넣어두는 레지스트리파일
+export const WIDGET_CATALOG = {
+ 'sprint-summary': {
+ layout: { i: 'sprint-summary', x: 0, y: 0, w: 12, h: 4, minW: 6, minH: 4 },
+ title: '스프린트 요약',
+ render: () => ,
+ },
+ 'my-tasks': {
+ layout: { i: 'my-tasks', x: 0, y: 0, w: 6, h: 5, minW: 2, minH: 3 },
+ title: '내 업무',
+ render: (size) => ,
+ },
+ velocity: {
+ layout: { i: 'velocity', x: 6, y: 0, w: 6, h: 5, minW: 4, minH: 4 },
+ title: '벨로시티',
+ render: () => ,
+ },
+ backlog: {
+ layout: { i: 'backlog', x: 0, y: 5, w: 6, h: 5, minW: 2, minH: 3 },
+ title: '백로그',
+ render: (size) => ,
+ },
+ 'recent-notes': {
+ layout: { i: 'recent-notes', x: 6, y: 5, w: 6, h: 5, minW: 2, minH: 3 },
+ title: '최근 회의록',
+ render: (size) => ,
+ },
+ 'recent-notices': {
+ layout: { i: 'recent-notices', x: 6, y: 5, w: 6, h: 5, minW: 2, minH: 3 },
+ title: '최근 공지',
+ render: (size) => ,
+ },
+ 'recent-resources': {
+ layout: { i: 'recent-resources', x: 0, y: 10, w: 6, h: 5, minW: 2, minH: 3 },
+ title: '최근 자료',
+ render: (size) => ,
+ },
+ 'today-schedule': {
+ layout: { i: 'today-schedule', x: 0, y: 10, w: 6, h: 4, minW: 2, minH: 3 },
+ title: '오늘 일정',
+ render: (size) => ,
+ },
+ calendar: {
+ layout: { i: 'calendar', x: 0, y: 14, w: 6, h: 8, minW: 4, minH: 6 },
+ title: '캘린더',
+ render: (size) => ,
+ },
+} satisfies Record;
+
+/** 카탈로그에 존재하는 위젯 id — 템플릿 목록 등에서 이 타입을 쓰면 오타·미존재 id가 컴파일 시점에 걸린다. */
+export type WidgetId = keyof typeof WIDGET_CATALOG;
diff --git a/src/views/dashboard/index.ts b/src/views/dashboard/index.ts
new file mode 100644
index 0000000..2da58dc
--- /dev/null
+++ b/src/views/dashboard/index.ts
@@ -0,0 +1,2 @@
+// dashboard 엔진의 Public API — 템플릿별 뷰가 위젯 레지스트리를 주입해 재사용한다.
+export { default as DashboardView } from './ui/DashboardView';
diff --git a/src/views/dashboard/ui/AddWidgetBar.tsx b/src/views/dashboard/ui/AddWidgetBar.tsx
new file mode 100644
index 0000000..9b5c9dc
--- /dev/null
+++ b/src/views/dashboard/ui/AddWidgetBar.tsx
@@ -0,0 +1,54 @@
+// 위젯 추가 바 — 편집 모드에서 그리드 아래 노출. 아직 배치되지 않은 위젯을 카탈로그에서 추가한다.
+'use client';
+
+import { useState } from 'react';
+import { Plus } from 'lucide-react';
+
+interface AvailableWidget {
+ id: string;
+ title: string;
+}
+
+interface AddWidgetBarProps {
+ available: AvailableWidget[];
+ onAdd: (id: string) => void;
+}
+
+export default function AddWidgetBar({ available, onAdd }: AddWidgetBarProps) {
+ const [open, setOpen] = useState(false);
+ const hasAvailable = available.length > 0;
+
+ return (
+
+
+
+ {open && hasAvailable && (
+
+ {available.map((widget) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/views/dashboard/ui/DashboardGrid.tsx b/src/views/dashboard/ui/DashboardGrid.tsx
new file mode 100644
index 0000000..8227882
--- /dev/null
+++ b/src/views/dashboard/ui/DashboardGrid.tsx
@@ -0,0 +1,116 @@
+// 대시보드 그리드 — react-grid-layout(v2)로 위젯 타일을 배치/드래그/리사이즈한다.
+// 카탈로그(widgets)와 배치(layout)를 위젯 id로 조인해 그린다. 카탈로그에 없는 id는 렌더에서 제외된다.
+// 편집 모드에서는 카드별 편집 chrome(이동 핸들·크기 뱃지·삭제)과 인디고 테두리가 노출되고,
+// 드래그는 좌상단 핸들(.rgl-drag-handle)로만 시작된다.
+//
+// 그리드는 컨테이너 실측 width에 의존하므로 SSR/hydration 시점에는 렌더하지 않고
+// 클라이언트 마운트 이후에만 렌더한다(useContainerWidth의 mounted로 SSR-안전하게 게이팅).
+'use client';
+
+import type { Ref } from 'react';
+import ReactGridLayout, { useContainerWidth } from 'react-grid-layout';
+import type { Layout, ResizeHandleAxis } from 'react-grid-layout';
+import { Maximize2, GripVertical, Trash2 } from 'lucide-react';
+
+import { getWidgetSize } from '@/shared/dashboard/lib/widget-size';
+import type { WidgetDefinition } from '@/shared/dashboard/model/widget.types';
+
+// 우하단 리사이즈 핸들 커스텀(원형). react-resizable 기본 클래스로 위치를 잡고 배경 삼각형은 제거한다.
+const renderResizeHandle = (axis: ResizeHandleAxis, ref: Ref) => (
+ }
+ className={`react-resizable-handle react-resizable-handle-${axis} border-brand/10 text-brand-muted -right-2! -bottom-2! flex! size-6! items-center justify-center rounded-full! border bg-white! bg-none! p-0! shadow-md transition-opacity [&::after]:hidden!`}
+ >
+
+
+);
+
+interface DashboardGridProps {
+ /** 위젯 카탈로그 (id → 렌더러) */
+ widgets: WidgetDefinition[];
+ layout: Layout;
+ editMode: boolean;
+ onLayoutChange: (layout: Layout) => void;
+ onRemove: (id: string) => void;
+}
+
+export default function DashboardGrid({
+ widgets,
+ layout,
+ editMode,
+ onLayoutChange,
+ onRemove,
+}: DashboardGridProps) {
+ // measureBeforeMount: mounted를 false로 시작해 SSR/hydration 렌더에서 그리드를 게이팅한다.
+ const { width, containerRef, mounted } = useContainerWidth({ measureBeforeMount: true });
+
+ const byId = new Map(widgets.map((widget) => [widget.layout.i, widget] as const));
+ // 카탈로그에 렌더러가 있는 항목만 — layout prop과 children이 항상 일치하도록 이 목록만 사용한다.
+ // 저장된 값은 위치(i,x,y,w,h)뿐이므로, 위젯 제약(minW/minH 등)은 카탈로그에서 머지한다.
+ const visibleLayout = layout
+ .filter((item) => byId.has(item.i))
+ .map((item) => ({ ...byId.get(item.i)!.layout, ...item }));
+
+ return (
+
+ {visibleLayout.length === 0 ? (
+
+
아직 추가된 위젯이 없습니다.
+
필요한 위젯을 추가해 워크스페이스를 구성해보세요.
+
+ ) : (
+ mounted &&
+ width > 0 && (
+
+ {visibleLayout.map((item) => {
+ const id = item.i;
+ const widget = byId.get(id)!;
+ const size = getWidgetSize(item.w, item.h);
+ return (
+
+ {editMode && (
+ <>
+ {/* 좌상단 이동 핸들 */}
+
+
+
+ {/* 상단 크기 뱃지 */}
+
+ {item.w}열×{item.h}행
+
+ {/* 우상단 삭제 */}
+
+ >
+ )}
+ {widget.render(size)}
+
+ );
+ })}
+
+ )
+ )}
+
+ );
+}
diff --git a/src/views/dashboard/ui/DashboardView.tsx b/src/views/dashboard/ui/DashboardView.tsx
new file mode 100644
index 0000000..330b07e
--- /dev/null
+++ b/src/views/dashboard/ui/DashboardView.tsx
@@ -0,0 +1,70 @@
+// 대시보드 — 카탈로그(위젯 전체)와 훅이 관리하는 배치(layout)를 id로 조인해 그린다.
+// · 배치 상태·추가/삭제·영속화 → edit-layout 훅 (레이아웃은 user_id+workspace_id로 조회/저장)
+// · 위젯 렌더 + 추가 기본 배치 → WIDGET_CATALOG (전역)
+// · 추가 메뉴 스코프 → TEMPLATE_WIDGETS[purpose] (템플릿별 허용 위젯)
+// · AppShell(사이드바/탑바)·폰트 → 상위 워크스페이스 layout 담당
+// 빈 상태로 시작하고, 편집 모드에서 이 템플릿이 허용하는 위젯을 추가해 구성한다.
+'use client';
+
+import {
+ DashboardEditToggle,
+ EditModeBanner,
+ useDashboardLayout,
+} from '@/features/dashboard/edit-layout';
+import type { DashboardLayoutState } from '@/entities/dashboard-layout';
+import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types';
+
+import { TEMPLATE_WIDGETS } from '../config/template-widgets';
+import { WIDGET_CATALOG, type WidgetId } from '../config/widget-catalog';
+import AddWidgetBar from './AddWidgetBar';
+import DashboardGrid from './DashboardGrid';
+
+const CATALOG_WIDGETS = Object.values(WIDGET_CATALOG);
+
+interface DashboardViewProps {
+ /** 레이아웃 영속화 키 */
+ workspaceId: string;
+ /** 워크스페이스 용도 — 추가 가능한 위젯을 템플릿별로 거른다(레이아웃 조회와는 무관) */
+ purpose: WorkspacePurpose;
+ /** 서버(RSC)에서 조회한 초기 레이아웃 */
+ initialLayout: DashboardLayoutState;
+ /** WORKSPACE_LAYOUTS.page_type — 한 워크스페이스의 여러 페이지를 구분 */
+ pageType?: string;
+}
+
+export default function DashboardView({
+ workspaceId,
+ purpose,
+ initialLayout,
+ pageType = 'dashboard',
+}: DashboardViewProps) {
+ const { layout, editMode, handleLayoutChange, addWidget, removeWidget, toggleEdit } =
+ useDashboardLayout({ workspaceId, pageType, initialLayout });
+
+ // 이 템플릿이 허용하는 위젯 중, 아직 배치되지 않은 것 = 추가 가능 목록
+ // TEMPLATE_WIDGETS[purpose]는 WidgetId[]라 카탈로그에 항상 존재한다.
+ const placed = new Set(layout.map((item) => item.i));
+ const available = TEMPLATE_WIDGETS[purpose]
+ .filter((id) => !placed.has(id))
+ .map((id) => ({ id, title: WIDGET_CATALOG[id].title }));
+
+ const handleAdd = (id: string) => {
+ if (!(id in WIDGET_CATALOG)) return;
+ addWidget(WIDGET_CATALOG[id as WidgetId].layout);
+ };
+
+ return (
+ <>
+ {editMode && }
+
+ {editMode && }
+
+ >
+ );
+}
diff --git a/src/views/project-management/ui/ProjectManagementPage.tsx b/src/views/project-management/ui/ProjectManagementPage.tsx
index 15838f9..748916b 100644
--- a/src/views/project-management/ui/ProjectManagementPage.tsx
+++ b/src/views/project-management/ui/ProjectManagementPage.tsx
@@ -10,9 +10,7 @@ type ProjectManagementPageProps = {
workspaceId: string;
};
-export default function ProjectManagementPage({
- workspaceId,
-}: ProjectManagementPageProps) {
+export default function ProjectManagementPage({ workspaceId }: ProjectManagementPageProps) {
return (
diff --git a/src/widgets/side-project/dashboard-backlog/index.ts b/src/widgets/side-project/dashboard-backlog/index.ts
new file mode 100644
index 0000000..38c0101
--- /dev/null
+++ b/src/widgets/side-project/dashboard-backlog/index.ts
@@ -0,0 +1,2 @@
+// dashboard-backlog 위젯의 Public API
+export { default as Backlog } from './ui/Backlog';
diff --git a/src/widgets/side-project/dashboard-backlog/ui/Backlog.tsx b/src/widgets/side-project/dashboard-backlog/ui/Backlog.tsx
new file mode 100644
index 0000000..845bbbf
--- /dev/null
+++ b/src/widgets/side-project/dashboard-backlog/ui/Backlog.tsx
@@ -0,0 +1,51 @@
+// 백로그 위젯 — 타일 크기에 따라 밀도가 다른 변형을 렌더
+// · sm: 최상위 항목 1건 + 외 N건
+// · md/lg: 우선순위 점 + 항목 + 포인트 리스트(넘치면 스크롤)
+// 워크스페이스의 백로그(스프린트 미편입) 업무를 셀렉터로 가져온다.
+import { currentSprint } from '@/entities/side-project/sprint';
+import { getBacklogTasks, type Task, TASK_PRIORITY } from '@/entities/side-project/task';
+import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
+import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
+
+const header = (
+
보드} />
+);
+
+const backlogItems: Task[] = getBacklogTasks(currentSprint.workspaceId);
+
+export default function Backlog({ size = 'md' }: { size?: WidgetSize }) {
+ if (size === 'sm') {
+ const [top, ...rest] = backlogItems;
+ return (
+
+ {header}
+
+
+ {top.title}
+ {rest.length > 0 && 외 {rest.length}건}
+
+
+ );
+ }
+
+ return (
+
+ {header}
+
+ {backlogItems.map((item) => (
+ -
+
+ {item.title}
+ {item.point}pt
+
+ ))}
+
+
+ );
+}
diff --git a/src/widgets/side-project/dashboard-calendar/index.ts b/src/widgets/side-project/dashboard-calendar/index.ts
new file mode 100644
index 0000000..8944879
--- /dev/null
+++ b/src/widgets/side-project/dashboard-calendar/index.ts
@@ -0,0 +1,2 @@
+// dashboard-calendar 위젯의 Public API
+export { default as Calendar } from './ui/Calendar';
diff --git a/src/widgets/side-project/dashboard-calendar/ui/Calendar.tsx b/src/widgets/side-project/dashboard-calendar/ui/Calendar.tsx
new file mode 100644
index 0000000..56805ee
--- /dev/null
+++ b/src/widgets/side-project/dashboard-calendar/ui/Calendar.tsx
@@ -0,0 +1,92 @@
+// 캘린더 위젯 — 월간 달력. 오늘 날짜 강조 + 이벤트 점 표시.
+// · sm: 오늘 날짜 + 이벤트 건수 요약
+// · md/lg: 월간 그리드(요일 헤더 + 날짜 셀)
+import { mockCalendar } from '@/entities/side-project/schedule-event';
+import { cn } from '@/shared/lib/utils';
+import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
+import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
+
+const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];
+
+// 해당 월의 날짜 셀을 요일 기준으로 배치(앞뒤 빈칸 포함, 7의 배수로 패딩)
+function buildMonthCells(year: number, month: number): (number | null)[] {
+ const firstWeekday = new Date(year, month - 1, 1).getDay();
+ const daysInMonth = new Date(year, month, 0).getDate();
+ const cells: (number | null)[] = Array.from({ length: firstWeekday }, () => null);
+ for (let day = 1; day <= daysInMonth; day += 1) cells.push(day);
+ while (cells.length % 7 !== 0) cells.push(null);
+ return cells;
+}
+
+export default function Calendar({ size = 'md' }: { size?: WidgetSize }) {
+ const { year, month, today, eventDays } = mockCalendar;
+
+ const header = (
+ 전체 보기}
+ />
+ );
+
+ if (size === 'sm') {
+ return (
+
+ {header}
+
+
{today ?? '-'}일
+
+ {month}월 · 일정 {eventDays.length}건
+
+
+
+ );
+ }
+
+ const cells = buildMonthCells(year, month);
+
+ return (
+
+ {header}
+
+ {WEEKDAYS.map((label, col) => (
+
0 && col < 6 && 'text-brand-muted',
+ )}
+ >
+ {label}
+
+ ))}
+
+ {cells.map((day, idx) => {
+ if (day === null) return
;
+ const col = idx % 7;
+ const isToday = day === today;
+ const hasEvent = eventDays.includes(day);
+ return (
+
+ 0 && col < 6 && 'text-brand-ink',
+ )}
+ >
+ {day}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/widgets/side-project/dashboard-my-tasks/index.ts b/src/widgets/side-project/dashboard-my-tasks/index.ts
new file mode 100644
index 0000000..7378f89
--- /dev/null
+++ b/src/widgets/side-project/dashboard-my-tasks/index.ts
@@ -0,0 +1,2 @@
+// dashboard-my-tasks 위젯의 Public API
+export { default as MyTasks } from './ui/MyTasks';
diff --git a/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx b/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx
new file mode 100644
index 0000000..9d783d4
--- /dev/null
+++ b/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx
@@ -0,0 +1,86 @@
+// 내 업무 위젯 — 타일 크기에 따라 밀도가 다른 변형을 렌더
+// · sm: 진행 중 개수 헤드라인 + 대기 건수 요약
+// · md: task 리스트(상태 뱃지)
+// · lg: 상태별 카운트 요약 + task 리스트
+// 현재 스프린트에 편입된 업무를 셀렉터로 가져온다(백로그는 애초에 포함되지 않음).
+import { currentSprint } from '@/entities/side-project/sprint';
+import { getSprintTasks, Task, TASK_STATUS, type TaskStatus } from '@/entities/side-project/task';
+import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
+import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
+
+const header = (
+ 전체 보기} />
+);
+
+// 현재 스프린트 편입 업무
+const sprintTasks: Task[] = getSprintTasks(currentSprint.id);
+const countBy = (status: TaskStatus) => sprintTasks.filter((task) => task.status === status).length;
+
+export default function MyTasks({ size = 'md' }: { size?: WidgetSize }) {
+ if (size === 'sm') {
+ return (
+
+ {header}
+
+
{countBy('in_progress')}
+
진행 중 · 대기 {countBy('todo')}건
+
+
+ );
+ }
+
+ const list = (
+
+ {sprintTasks.map((task) => {
+ const status = TASK_STATUS[task.status];
+ return (
+ -
+
+ {task.title}
+ {task.point}pt
+
+ {status.label}
+
+
+ );
+ })}
+
+ );
+
+ if (size === 'lg') {
+ return (
+
+ {header}
+
+
+ 진행 중{' '}
+
+ {countBy('in_progress')}
+
+
+
+ 대기 {countBy('todo')}
+
+
+ 완료 {countBy('done')}
+
+
+ {list}
+
+ );
+ }
+
+ // md
+ return (
+
+ {header}
+ {list}
+
+ );
+}
diff --git a/src/widgets/side-project/dashboard-recent-notes/index.ts b/src/widgets/side-project/dashboard-recent-notes/index.ts
new file mode 100644
index 0000000..9205a81
--- /dev/null
+++ b/src/widgets/side-project/dashboard-recent-notes/index.ts
@@ -0,0 +1,2 @@
+// dashboard-recent-notes 위젯의 Public API
+export { default as RecentNotes } from './ui/RecentNotes';
diff --git a/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx b/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx
new file mode 100644
index 0000000..696e233
--- /dev/null
+++ b/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx
@@ -0,0 +1,67 @@
+// 최근 회의록 위젯 — 타일 크기에 따라 밀도가 다른 변형을 렌더
+// · sm: 가장 최근 회의록 1건(제목만)
+// · md: 리스트(제목 + 작성일)
+// · lg: 총 개수 + 리스트(제목 + 본문 미리보기 + 작성일)
+import { FileText } from 'lucide-react';
+
+import { mockMeetingNotes } from '@/entities/side-project/meeting-note';
+import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
+import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
+
+const header = (
+ 전체 보기} />
+);
+
+export default function RecentNotes({ size = 'md' }: { size?: WidgetSize }) {
+ if (size === 'sm') {
+ const latest = mockMeetingNotes[0];
+ return (
+
+ {header}
+
+
+ {latest.title}
+
+
+ );
+ }
+
+ if (size === 'lg') {
+ return (
+
+ {header}
+ 총 {mockMeetingNotes.length}개의 회의록
+
+ {mockMeetingNotes.map((note) => (
+ -
+
+
+
{note.title}
+
{note.summary}
+
{note.date}
+
+
+ ))}
+
+
+ );
+ }
+
+ // md — 리스트(제목 + 작성일)
+ return (
+
+ {header}
+
+ {mockMeetingNotes.map((note) => (
+ -
+
+
+
{note.title}
+
{note.date}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/widgets/side-project/dashboard-sprint-summary/index.ts b/src/widgets/side-project/dashboard-sprint-summary/index.ts
new file mode 100644
index 0000000..16e2868
--- /dev/null
+++ b/src/widgets/side-project/dashboard-sprint-summary/index.ts
@@ -0,0 +1,2 @@
+// dashboard-sprint-summary 위젯의 Public API
+export { default as SprintSummary } from './ui/SprintSummary';
diff --git a/src/widgets/side-project/dashboard-sprint-summary/ui/SprintSummary.tsx b/src/widgets/side-project/dashboard-sprint-summary/ui/SprintSummary.tsx
new file mode 100644
index 0000000..ec55bd6
--- /dev/null
+++ b/src/widgets/side-project/dashboard-sprint-summary/ui/SprintSummary.tsx
@@ -0,0 +1,65 @@
+// 스프린트 요약 위젯 — 스프린트 배너 + 포인트 통계 3종을 하나의 카드로 조립
+// 통계(계획/완료/남은)와 기간 표시는 currentSprint 메타에서 파생한다.
+import { currentSprint } from '@/entities/side-project/sprint';
+import { StatCard, type Stat } from '@/shared/dashboard/ui/stat-card';
+
+// 'YYYY-MM-DD' → 'M/D'
+const monthDay = (iso: string) => {
+ const [, month, day] = iso.split('-');
+ return `${Number(month)}/${Number(day)}`;
+};
+
+export default function SprintSummary() {
+ const { name, startDate, endDate, daysLeft, totalPoints, completedPoints } = currentSprint;
+ const period = `${monthDay(startDate)} – ${monthDay(endDate)}`;
+
+ const planned: Stat = {
+ id: 'planned',
+ label: '계획 포인트',
+ value: totalPoints,
+ unit: 'pt',
+ color: '#155dfc',
+ };
+ const done: Stat = {
+ id: 'done',
+ label: '완료 포인트',
+ value: completedPoints,
+ unit: 'pt',
+ color: '#00a63e',
+ };
+ const remaining: Stat = {
+ id: 'remaining',
+ label: '남은 포인트',
+ value: totalPoints - completedPoints,
+ unit: 'pt',
+ color: '#e17100',
+ };
+
+ return (
+
+ {/* 스프린트 배너 (그라데이션) */}
+
+
+
+ {daysLeft}일
+ 남음
+
+
+
+ {/* 포인트 통계 3종 (계획/완료 상단, 남은 하단 전체 폭) */}
+
+
+ );
+}
diff --git a/src/widgets/side-project/dashboard-today-schedule/index.ts b/src/widgets/side-project/dashboard-today-schedule/index.ts
new file mode 100644
index 0000000..eb108d1
--- /dev/null
+++ b/src/widgets/side-project/dashboard-today-schedule/index.ts
@@ -0,0 +1,2 @@
+// dashboard-today-schedule 위젯의 Public API
+export { default as TodaySchedule } from './ui/TodaySchedule';
diff --git a/src/widgets/side-project/dashboard-today-schedule/ui/TodaySchedule.tsx b/src/widgets/side-project/dashboard-today-schedule/ui/TodaySchedule.tsx
new file mode 100644
index 0000000..7cc81be
--- /dev/null
+++ b/src/widgets/side-project/dashboard-today-schedule/ui/TodaySchedule.tsx
@@ -0,0 +1,58 @@
+// 오늘 일정 위젯 — 타일 크기에 따라 밀도가 다른 변형을 렌더
+// · sm: 다음 일정 1건(액센트 바 + 제목 + 시간 + 외 N건)
+// · md: 3건 리스트
+// · lg: 전체 리스트 (일정 유형별 액센트 바 색상 — 마감=빨강)
+import { mockTodaySchedule, SCHEDULE_TYPE_COLOR } from '@/entities/side-project/schedule-event';
+import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
+import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
+
+const header = (
+ 전체 보기} />
+);
+
+export default function TodaySchedule({ size = 'md' }: { size?: WidgetSize }) {
+ if (size === 'sm') {
+ const [next, ...rest] = mockTodaySchedule;
+ return (
+
+ {header}
+
+
+
+
다음 일정
+
{next.title}
+
+ {next.time}
+ {rest.length > 0 && · 외 {rest.length}건}
+
+
+
+
+ );
+ }
+
+ // md: 3건, lg: 전체
+ const events = size === 'md' ? mockTodaySchedule.slice(0, 3) : mockTodaySchedule;
+ return (
+
+ {header}
+
+ {events.map((event) => (
+ -
+
+
+
{event.title}
+
{event.time}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/widgets/side-project/dashboard-velocity/index.ts b/src/widgets/side-project/dashboard-velocity/index.ts
new file mode 100644
index 0000000..400efa5
--- /dev/null
+++ b/src/widgets/side-project/dashboard-velocity/index.ts
@@ -0,0 +1,2 @@
+// dashboard-velocity 위젯의 Public API
+export { default as Velocity } from './ui/Velocity';
diff --git a/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx b/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx
new file mode 100644
index 0000000..20916ef
--- /dev/null
+++ b/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx
@@ -0,0 +1,44 @@
+// 벨로시티 위젯 — 스프린트별 계획/완료 포인트를 막대로 비교
+// 막대가 2그룹뿐이라 별도 차트 라이브러리 없이 순수 CSS(div height %)로 구현한다.
+import { sprintVelocity, VELOCITY_MAX } from '@/entities/side-project/sprint';
+import { WidgetCard, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
+
+export default function Velocity() {
+ return (
+
+
+
+ {/* Y축 눈금 */}
+
+ {VELOCITY_MAX}
+ {VELOCITY_MAX / 2}
+ 0
+
+
+
+
+ {sprintVelocity.map((point) => (
+
+ ))}
+
+
+ {sprintVelocity.map((point) => (
+ {point.sprint}
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/widgets/store-operation/dashboard-recent-notices/index.ts b/src/widgets/store-operation/dashboard-recent-notices/index.ts
new file mode 100644
index 0000000..52e9591
--- /dev/null
+++ b/src/widgets/store-operation/dashboard-recent-notices/index.ts
@@ -0,0 +1,2 @@
+// dashboard-recent-notices 위젯의 Public API
+export { default as RecentNotices } from './ui/RecentNotices';
diff --git a/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx b/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx
new file mode 100644
index 0000000..0924b69
--- /dev/null
+++ b/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx
@@ -0,0 +1,91 @@
+// 최근 공지 위젯 — 타일 크기에 따라 밀도가 다른 변형을 렌더
+// · sm: 가장 최근 공지 1건(제목만)
+// · md: 리스트(제목 + 작성자·작성일)
+// · lg: 총 개수 + 리스트(제목 + 본문 미리보기 + 작성자·작성일)
+import { Bell, Pin } from 'lucide-react';
+
+import { mockNotices, type Notice } from '@/entities/notice';
+import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
+import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
+import { cn } from '@/shared/lib/utils';
+
+// 고정 공지 우선 → 작성일(내림차순) 정렬. 원본 배열을 변형하지 않도록 복사 후 정렬한다.
+const sortedNotices = [...mockNotices].sort((a, b) => {
+ if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
+ return b.createdAt.localeCompare(a.createdAt);
+});
+
+const header = (
+ 전체 보기} />
+);
+
+// 공지 앞머리 아이콘 — 고정 공지는 노란색 Pin, 일반 공지는 Bell로 구분한다.
+// className에는 레이아웃(크기·정렬)만 전달하고, 색상은 고정 여부에 따라 여기서 정한다.
+function NoticeIcon({ isPinned, className }: { isPinned: boolean; className?: string }) {
+ const Icon = isPinned ? Pin : Bell;
+ return (
+
+ );
+}
+
+// "작성자 · 작성일" 형태의 메타 텍스트
+function noticeMeta(notice: Notice) {
+ return `${notice.authorName} · ${notice.createdAt}`;
+}
+
+export default function RecentNotices({ size = 'md' }: { size?: WidgetSize }) {
+ if (size === 'sm') {
+ const latest = sortedNotices[0];
+ return (
+
+ {header}
+
+
+ {latest.title}
+
+
+ );
+ }
+
+ if (size === 'lg') {
+ return (
+
+ {header}
+ 총 {sortedNotices.length}개의 공지
+
+ {sortedNotices.map((notice) => (
+ -
+
+
+
{notice.title}
+
{notice.content}
+
{noticeMeta(notice)}
+
+
+ ))}
+
+
+ );
+ }
+
+ // md — 리스트(제목 + 작성자·작성일)
+ return (
+
+ {header}
+
+ {sortedNotices.map((notice) => (
+ -
+
+
+
{notice.title}
+
{noticeMeta(notice)}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/widgets/store-operation/dashboard-recent-resources/index.ts b/src/widgets/store-operation/dashboard-recent-resources/index.ts
new file mode 100644
index 0000000..91c1ef5
--- /dev/null
+++ b/src/widgets/store-operation/dashboard-recent-resources/index.ts
@@ -0,0 +1,2 @@
+// dashboard-recent-resources 위젯의 Public API
+export { default as RecentResources } from './ui/RecentResources';
diff --git a/src/widgets/store-operation/dashboard-recent-resources/ui/RecentResources.tsx b/src/widgets/store-operation/dashboard-recent-resources/ui/RecentResources.tsx
new file mode 100644
index 0000000..3ade66d
--- /dev/null
+++ b/src/widgets/store-operation/dashboard-recent-resources/ui/RecentResources.tsx
@@ -0,0 +1,99 @@
+// 최근 자료 위젯 — 타일 크기에 따라 밀도가 다른 변형을 렌더
+// · sm: 가장 최근 자료 1건(제목만)
+// · md: 리스트(제목 + 업로더)
+// · lg: 총 개수 + 리스트(제목 + 설명 미리보기 + 업로더)
+import { Archive, GitBranch, Link } from 'lucide-react';
+
+import { mockResources, type ResourceItem } from '@/entities/resource';
+import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
+import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
+import { cn } from '@/shared/lib/utils';
+
+// 작성일(내림차순) 정렬 — "최근" 자료를 위로. 원본 배열을 변형하지 않도록 복사 후 정렬한다.
+const sortedResources = [...mockResources].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+
+const header = (
+ 자료실} />
+);
+
+// 자료 타입별 앞머리 아이콘 — 파일은 Archive, github 링크는 GitBranch, 그 외 링크는 Link.
+// (자료실 ResourceList.getResourceIcon과 동일한 규칙)
+function ResourceGlyph({ resource }: { resource: ResourceItem }) {
+ if (resource.resourceType === 'file') return ;
+ if (resource.linkProvider === 'github')
+ return ;
+ return ;
+}
+
+// 아이콘을 감싸는 타입별 색상 타일 — 파일은 인디고, 링크는 블루로 구분한다(자료실 페이지 기준).
+function ResourceIcon({ resource, className }: { resource: ResourceItem; className?: string }) {
+ const isFile = resource.resourceType === 'file';
+ return (
+
+
+
+ );
+}
+
+export default function RecentResources({ size = 'md' }: { size?: WidgetSize }) {
+ if (size === 'sm') {
+ const latest = sortedResources[0];
+ return (
+
+ {header}
+
+
+ {latest.title}
+
+
+ );
+ }
+
+ if (size === 'lg') {
+ return (
+
+ {header}
+ 총 {sortedResources.length}개의 자료
+
+ {sortedResources.map((resource) => (
+ -
+
+
+
{resource.title}
+
{resource.description}
+
{resource.uploadedBy}
+
+
+ ))}
+
+
+ );
+ }
+
+ // md — 리스트(제목 + 업로더)
+ return (
+
+ {header}
+
+ {sortedResources.map((resource) => (
+ -
+
+
+
{resource.title}
+
{resource.uploadedBy}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx b/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx
index 7931544..8903d3b 100644
--- a/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx
+++ b/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx
@@ -11,9 +11,7 @@ interface WorkspaceHeaderProps {
}
function getCurrentPageTitle(pathname: string, navigationItems: WorkspaceNavigationItem[]): string {
- const currentNavigationItem = navigationItems.find((item) =>
- pathname.endsWith(`/${item.href}`),
- );
+ const currentNavigationItem = navigationItems.find((item) => pathname.endsWith(`/${item.href}`));
return currentNavigationItem?.label ?? '대시보드';
}