From 99f65cbe5bd3c4ea02a593f520c13c19b83cb111 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=A7=80=EC=9B=85?=
Date: Mon, 13 Jul 2026 16:54:37 +0900
Subject: [PATCH 1/4] =?UTF-8?q?[Feat]=20=ED=8C=80=20=ED=94=84=EB=A1=9C?=
=?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?=
=?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/entities/progress-chart/index.ts | 1 +
.../model/create-progress-chart-summary.ts | 78 ++++++
.../task/model/mock-tasks-by-workspace.ts | 248 +++++++++++++++++-
.../model/mock-workspace-members.ts | 31 ++-
.../model/progress-chart.ts | 81 +-----
.../dashboard/config/template-widgets.ts | 10 +-
src/views/dashboard/config/widget-catalog.tsx | 14 +-
.../dashboard-overall-progress/index.ts | 1 +
.../ui/OverallProgress.tsx | 65 +++++
.../dashboard-work-summary/index.ts | 1 +
.../dashboard-work-summary/ui/WorkSummary.tsx | 100 +++++++
11 files changed, 545 insertions(+), 85 deletions(-)
create mode 100644 src/entities/progress-chart/model/create-progress-chart-summary.ts
create mode 100644 src/widgets/team-project/dashboard-overall-progress/index.ts
create mode 100644 src/widgets/team-project/dashboard-overall-progress/ui/OverallProgress.tsx
create mode 100644 src/widgets/team-project/dashboard-work-summary/index.ts
create mode 100644 src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx
diff --git a/src/entities/progress-chart/index.ts b/src/entities/progress-chart/index.ts
index 5d441d3..6fe98b1 100644
--- a/src/entities/progress-chart/index.ts
+++ b/src/entities/progress-chart/index.ts
@@ -3,3 +3,4 @@ export type {
ProgressChartAssigneeItem,
ProgressChartStatusItem,
} from './model/progress-chart.types';
+export { createProgressChartSummary } from './model/create-progress-chart-summary';
diff --git a/src/entities/progress-chart/model/create-progress-chart-summary.ts b/src/entities/progress-chart/model/create-progress-chart-summary.ts
new file mode 100644
index 0000000..6d9406c
--- /dev/null
+++ b/src/entities/progress-chart/model/create-progress-chart-summary.ts
@@ -0,0 +1,78 @@
+import type { Task, TaskStatus } from '@/entities/task';
+
+import type {
+ ProgressChartAssigneeItem,
+ ProgressChartStatusItem,
+ ProgressChartSummary,
+} from './progress-chart.types';
+
+const STATUS_META: Record> = {
+ done: {
+ label: '완료',
+ color: '#574BEA',
+ },
+ 'in-progress': {
+ label: '진행 중',
+ color: '#7D6AF3',
+ },
+ todo: {
+ label: '대기',
+ color: '#DED9FF',
+ },
+};
+
+function toPercentage(value: number, total: number) {
+ if (total === 0) {
+ return 0;
+ }
+
+ return Math.round((value / total) * 100);
+}
+
+function countByStatus(tasks: Task[], status: TaskStatus) {
+ return tasks.filter((task) => task.status === status).length;
+}
+
+export function createProgressChartSummary(tasks: Task[]): ProgressChartSummary {
+ const totalTaskCount = tasks.length;
+ const doneTaskCount = countByStatus(tasks, 'done');
+ const inProgressTaskCount = countByStatus(tasks, 'in-progress');
+ const overallProgressRate = toPercentage(doneTaskCount, totalTaskCount);
+
+ const assigneeMap = new Map();
+ tasks.forEach((task) => {
+ const current = assigneeMap.get(task.assignee) ?? {
+ name: task.assignee,
+ count: 0,
+ };
+
+ current.count += 1;
+ assigneeMap.set(task.assignee, current);
+ });
+
+ const assigneeItems = [...assigneeMap.values()].sort((left, right) => {
+ if (right.count !== left.count) {
+ return right.count - left.count;
+ }
+
+ return left.name.localeCompare(right.name, 'ko');
+ });
+
+ const statusItems: ProgressChartStatusItem[] = (
+ Object.entries(STATUS_META) as Array<[TaskStatus, (typeof STATUS_META)[TaskStatus]]>
+ ).map(([status, meta]) => ({
+ id: status,
+ label: meta.label,
+ count: countByStatus(tasks, status),
+ color: meta.color,
+ }));
+
+ return {
+ totalTaskCount,
+ doneTaskCount,
+ inProgressTaskCount,
+ overallProgressRate,
+ assigneeItems,
+ statusItems,
+ };
+}
diff --git a/src/entities/task/model/mock-tasks-by-workspace.ts b/src/entities/task/model/mock-tasks-by-workspace.ts
index ed0eaa0..dc2381d 100644
--- a/src/entities/task/model/mock-tasks-by-workspace.ts
+++ b/src/entities/task/model/mock-tasks-by-workspace.ts
@@ -1,5 +1,9 @@
import type { Task } from './task.types';
+const TEAM_PROJECT_WORKSPACE_UUID = '00000000-0000-0000-0000-000000001001';
+const SIDE_PROJECT_WORKSPACE_UUID = '00000000-0000-0000-0000-000000001002';
+const STORE_OPERATION_WORKSPACE_UUID = '00000000-0000-0000-0000-000000001003';
+
const mockTasksByWorkspaceId: Record = {
test: [
{
@@ -73,8 +77,248 @@ const mockTasksByWorkspaceId: Record = {
status: 'done',
},
],
+ 'team-workspace': [
+ {
+ id: 'task-1',
+ workspaceId: 'team-workspace',
+ title: '사용자 인터뷰 설문지 제작',
+ assignee: '박서준',
+ assigneeInitial: '박',
+ assigneeColor: '#00C950',
+ dueDate: '7/5',
+ status: 'todo',
+ },
+ {
+ id: 'task-2',
+ workspaceId: 'team-workspace',
+ title: 'DB 스키마 설계',
+ assignee: '김지은',
+ assigneeInitial: '김',
+ assigneeColor: '#FE9A00',
+ dueDate: '7/6',
+ status: 'todo',
+ },
+ {
+ id: 'task-3',
+ workspaceId: 'team-workspace',
+ title: '스프린트 1 회고 준비',
+ assignee: '이하은',
+ assigneeInitial: '이',
+ assigneeColor: '#615FFF',
+ dueDate: '7/10',
+ status: 'todo',
+ },
+ {
+ id: 'task-4',
+ workspaceId: 'team-workspace',
+ title: '와이어프레임 초안 작성',
+ assignee: '김지은',
+ assigneeInitial: '김',
+ assigneeColor: '#00B8DB',
+ dueDate: '7/3',
+ status: 'in-progress',
+ },
+ {
+ id: 'task-5',
+ workspaceId: 'team-workspace',
+ title: '랜딩 페이지 디자인',
+ assignee: '최민준',
+ assigneeInitial: '최',
+ assigneeColor: '#2B7FFF',
+ dueDate: '7/8',
+ status: 'in-progress',
+ },
+ {
+ id: 'task-6',
+ workspaceId: 'team-workspace',
+ title: 'API 명세서 문서화',
+ assignee: '이하은',
+ assigneeInitial: '이',
+ assigneeColor: '#615FFF',
+ dueDate: '7/2',
+ status: 'done',
+ },
+ {
+ id: 'task-7',
+ workspaceId: 'team-workspace',
+ title: '로고 시안 3종 작성',
+ assignee: '박서준',
+ assigneeInitial: '박',
+ assigneeColor: '#FE9A00',
+ dueDate: '6/30',
+ status: 'done',
+ },
+ {
+ id: 'task-8',
+ workspaceId: 'team-workspace',
+ title: '중간 발표 리허설 진행',
+ assignee: '최민준',
+ assigneeInitial: '최',
+ assigneeColor: '#2B7FFF',
+ dueDate: '7/12',
+ status: 'in-progress',
+ },
+ {
+ id: 'task-9',
+ workspaceId: 'team-workspace',
+ title: '최종 보고서 목차 정리',
+ assignee: '김지은',
+ assigneeInitial: '김',
+ assigneeColor: '#FE9A00',
+ dueDate: '7/14',
+ status: 'todo',
+ },
+ {
+ id: 'task-10',
+ workspaceId: 'team-workspace',
+ title: '인터뷰 대상자 일정 조율',
+ assignee: '이하은',
+ assigneeInitial: '이',
+ assigneeColor: '#615FFF',
+ dueDate: '7/16',
+ status: 'todo',
+ },
+ {
+ id: 'task-11',
+ workspaceId: 'team-workspace',
+ title: '프로토타입 수정 사항 반영',
+ assignee: '최민준',
+ assigneeInitial: '최',
+ assigneeColor: '#2B7FFF',
+ dueDate: '7/4',
+ status: 'done',
+ },
+ {
+ id: 'task-12',
+ workspaceId: 'team-workspace',
+ title: '경쟁 서비스 비교표 작성',
+ assignee: '박서준',
+ assigneeInitial: '박',
+ assigneeColor: '#00C950',
+ dueDate: '7/1',
+ status: 'done',
+ },
+ {
+ id: 'task-13',
+ workspaceId: 'team-workspace',
+ title: '발표 스크립트 1차 작성',
+ assignee: '김지은',
+ assigneeInitial: '김',
+ assigneeColor: '#FE9A00',
+ dueDate: '7/2',
+ status: 'done',
+ },
+ {
+ id: 'task-14',
+ workspaceId: 'team-workspace',
+ title: '사용성 테스트 결과 정리',
+ assignee: '이하은',
+ assigneeInitial: '이',
+ assigneeColor: '#615FFF',
+ dueDate: '7/3',
+ status: 'done',
+ },
+ {
+ id: 'task-15',
+ workspaceId: 'team-workspace',
+ title: '발표 자료 시각 보정',
+ assignee: '정우진',
+ assigneeInitial: '정',
+ assigneeColor: '#8B5CF6',
+ dueDate: '7/5',
+ status: 'done',
+ },
+ ],
+ 'side-workspace': [
+ {
+ id: 'side-task-1',
+ workspaceId: 'side-workspace',
+ title: '온보딩 플로우 개선',
+ assignee: '최민준',
+ assigneeInitial: '최',
+ assigneeColor: '#2B7FFF',
+ dueDate: '7/14',
+ status: 'todo',
+ },
+ {
+ id: 'side-task-2',
+ workspaceId: 'side-workspace',
+ title: '베타 테스터 모집 공고',
+ assignee: '김지은',
+ assigneeInitial: '김',
+ assigneeColor: '#FE9A00',
+ dueDate: '7/15',
+ status: 'todo',
+ },
+ {
+ id: 'side-task-3',
+ workspaceId: 'side-workspace',
+ title: '운동 통계 차트',
+ assignee: '박서준',
+ assigneeInitial: '박',
+ assigneeColor: '#00B8DB',
+ dueDate: '7/12',
+ status: 'in-progress',
+ },
+ {
+ id: 'side-task-4',
+ workspaceId: 'side-workspace',
+ title: '푸시 알림 설정',
+ assignee: '김지은',
+ assigneeInitial: '김',
+ assigneeColor: '#FE9A00',
+ dueDate: '7/11',
+ status: 'in-progress',
+ },
+ {
+ id: 'side-task-5',
+ workspaceId: 'side-workspace',
+ title: '소셜 로그인 연동',
+ assignee: '이하은',
+ assigneeInitial: '이',
+ assigneeColor: '#615FFF',
+ dueDate: '7/9',
+ status: 'done',
+ },
+ {
+ id: 'side-task-6',
+ workspaceId: 'side-workspace',
+ title: '운동 기록 CRUD API',
+ assignee: '이하은',
+ assigneeInitial: '이',
+ assigneeColor: '#615FFF',
+ dueDate: '7/8',
+ status: 'done',
+ },
+ ],
};
-export function getMockTasksByWorkspaceId(workspaceId: string): Task[] {
- return mockTasksByWorkspaceId[workspaceId]?.map((task) => ({ ...task })) ?? [];
+const taskWorkspaceAliasById: Record = {
+ [TEAM_PROJECT_WORKSPACE_UUID]: 'team-workspace',
+ [SIDE_PROJECT_WORKSPACE_UUID]: 'side-workspace',
+ [STORE_OPERATION_WORKSPACE_UUID]: 'store-workspace',
+};
+
+function resolveTaskWorkspaceKey(
+ workspaceId: string,
+ fallbackWorkspaceId?: keyof typeof mockTasksByWorkspaceId,
+) {
+ if (workspaceId in mockTasksByWorkspaceId) {
+ return workspaceId as keyof typeof mockTasksByWorkspaceId;
+ }
+
+ return taskWorkspaceAliasById[workspaceId] ?? fallbackWorkspaceId;
+}
+
+export function getMockTasksByWorkspaceId(
+ workspaceId: string,
+ fallbackWorkspaceId?: keyof typeof mockTasksByWorkspaceId,
+): Task[] {
+ const resolvedWorkspaceId = resolveTaskWorkspaceKey(workspaceId, fallbackWorkspaceId);
+
+ if (!resolvedWorkspaceId) {
+ return [];
+ }
+
+ return mockTasksByWorkspaceId[resolvedWorkspaceId]?.map((task) => ({ ...task })) ?? [];
}
diff --git a/src/entities/workspace-member/model/mock-workspace-members.ts b/src/entities/workspace-member/model/mock-workspace-members.ts
index 35ebf39..3ca2556 100644
--- a/src/entities/workspace-member/model/mock-workspace-members.ts
+++ b/src/entities/workspace-member/model/mock-workspace-members.ts
@@ -119,6 +119,24 @@ export const mockWorkspaceMembers: WorkspaceMember[] = [
role: 'member',
status: 'invited',
},
+ {
+ workspaceId: 'team-workspace',
+ userId: 'user-4',
+ workspaceNickname: '최민준',
+ avatarLabel: '최',
+ email: 'minjun@example.com',
+ role: 'member',
+ status: 'joined',
+ },
+ {
+ workspaceId: 'team-workspace',
+ userId: 'user-5',
+ workspaceNickname: '정우진',
+ avatarLabel: '정',
+ email: 'woojin@example.com',
+ role: 'member',
+ status: 'joined',
+ },
{
workspaceId: 'side-workspace',
userId: 'user-1',
@@ -148,6 +166,17 @@ export const mockWorkspaceMembers: WorkspaceMember[] = [
},
];
+const TEAM_PROJECT_WORKSPACE_UUID = '00000000-0000-0000-0000-000000001001';
+const SIDE_PROJECT_WORKSPACE_UUID = '00000000-0000-0000-0000-000000001002';
+const STORE_OPERATION_WORKSPACE_UUID = '00000000-0000-0000-0000-000000001003';
+
+const workspaceMemberAliasById: Record = {
+ [TEAM_PROJECT_WORKSPACE_UUID]: 'team-workspace',
+ [SIDE_PROJECT_WORKSPACE_UUID]: 'side-workspace',
+ [STORE_OPERATION_WORKSPACE_UUID]: 'store-workspace',
+};
+
export function getMockWorkspaceMembersByWorkspaceId(workspaceId: string): WorkspaceMember[] {
- return mockWorkspaceMembers.filter((member) => member.workspaceId === workspaceId);
+ const resolvedWorkspaceId = workspaceMemberAliasById[workspaceId] ?? workspaceId;
+ return mockWorkspaceMembers.filter((member) => member.workspaceId === resolvedWorkspaceId);
}
diff --git a/src/features/manage-progress-chart/model/progress-chart.ts b/src/features/manage-progress-chart/model/progress-chart.ts
index 1c90bd6..4459e49 100644
--- a/src/features/manage-progress-chart/model/progress-chart.ts
+++ b/src/features/manage-progress-chart/model/progress-chart.ts
@@ -1,80 +1 @@
-import type { Task, TaskStatus } from '@/entities/task';
-import type {
- ProgressChartAssigneeItem,
- ProgressChartStatusItem,
- ProgressChartSummary,
-} from '@/entities/progress-chart';
-
-// 진행률 차트는 별도 목업 숫자를 두지 않고 프로젝트 관리 task 목록에서 직접 파생한다.
-const STATUS_META: Record> = {
- done: {
- label: '완료',
- color: '#574BEA',
- },
- 'in-progress': {
- label: '진행 중',
- color: '#7D6AF3',
- },
- todo: {
- label: '대기',
- color: '#DED9FF',
- },
-};
-
-function toPercentage(value: number, total: number) {
- if (total === 0) {
- return 0;
- }
-
- return Math.round((value / total) * 100);
-}
-
-function countByStatus(tasks: Task[], status: TaskStatus) {
- return tasks.filter((task) => task.status === status).length;
-}
-
-export function createProgressChartSummary(tasks: Task[]): ProgressChartSummary {
- const totalTaskCount = tasks.length;
- const doneTaskCount = countByStatus(tasks, 'done');
- const inProgressTaskCount = countByStatus(tasks, 'in-progress');
- const overallProgressRate = toPercentage(doneTaskCount, totalTaskCount);
-
- // 담당자별 막대 차트는 "담당자가 가진 전체 업무 수"를 기준으로 집계한다.
- const assigneeMap = new Map();
- tasks.forEach((task) => {
- const current = assigneeMap.get(task.assignee) ?? {
- name: task.assignee,
- count: 0,
- };
-
- current.count += 1;
- assigneeMap.set(task.assignee, current);
- });
-
- const assigneeItems = [...assigneeMap.values()].sort((left, right) => {
- if (right.count !== left.count) {
- return right.count - left.count;
- }
-
- return left.name.localeCompare(right.name, 'ko');
- });
-
- // 도넛 차트 범례도 task status 집계값을 그대로 사용한다.
- const statusItems: ProgressChartStatusItem[] = (
- Object.entries(STATUS_META) as Array<[TaskStatus, (typeof STATUS_META)[TaskStatus]]>
- ).map(([status, meta]) => ({
- id: status,
- label: meta.label,
- count: countByStatus(tasks, status),
- color: meta.color,
- }));
-
- return {
- totalTaskCount,
- doneTaskCount,
- inProgressTaskCount,
- overallProgressRate,
- assigneeItems,
- statusItems,
- };
-}
+export { createProgressChartSummary } from '@/entities/progress-chart';
diff --git a/src/views/dashboard/config/template-widgets.ts b/src/views/dashboard/config/template-widgets.ts
index b44c28d..171fc67 100644
--- a/src/views/dashboard/config/template-widgets.ts
+++ b/src/views/dashboard/config/template-widgets.ts
@@ -22,5 +22,13 @@ export const TEMPLATE_WIDGETS: Record = {
'store-operation': ['work-schedule', 'calendar', 'recent-notices', 'recent-resources'],
// TODO: 팀플 템플릿에 들어가는 위젯 생성, 추가, 수정
// 팀 프로젝트 — 진척·협업
- 'team-project': ['my-tasks', 'recent-notes', 'calendar', 'recent-notices', 'recent-resources'],
+ 'team-project': [
+ 'my-tasks',
+ 'recent-notes',
+ 'overall-progress',
+ 'work-summary',
+ 'calendar',
+ 'recent-notices',
+ 'recent-resources',
+ ],
};
diff --git a/src/views/dashboard/config/widget-catalog.tsx b/src/views/dashboard/config/widget-catalog.tsx
index 61690b7..c4149fd 100644
--- a/src/views/dashboard/config/widget-catalog.tsx
+++ b/src/views/dashboard/config/widget-catalog.tsx
@@ -13,6 +13,8 @@ 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';
import { WorkScheduleSummary } from '@/widgets/store-operation/dashboard-work-schedule';
+import { OverallProgress } from '@/widgets/team-project/dashboard-overall-progress';
+import { WorkSummary } from '@/widgets/team-project/dashboard-work-summary';
// layout의 x/y는 "추가될 때의 기본 위치"이며, 그리드가 충돌 시 자동 정렬한다.
// key는 layout.i(위젯 id)와 일치해야 한다.
@@ -65,8 +67,18 @@ export const WIDGET_CATALOG = {
title: '오늘 일정',
render: (size) => ,
},
+ 'overall-progress': {
+ layout: { i: 'overall-progress', x: 6, y: 10, w: 6, h: 5, minW: 3, minH: 4 },
+ title: '전체 진행률',
+ render: (size, { workspaceId }) => ,
+ },
+ 'work-summary': {
+ layout: { i: 'work-summary', x: 0, y: 0, w: 12, h: 5, minW: 6, minH: 4 },
+ title: '업무 요약',
+ render: (size, { workspaceId }) => ,
+ },
calendar: {
- layout: { i: 'calendar', x: 0, y: 14, w: 6, h: 8, minW: 4, minH: 6 },
+ layout: { i: 'calendar', x: 6, y: 14, w: 6, h: 8, minW: 4, minH: 6 },
title: '캘린더',
render: (size) => ,
},
diff --git a/src/widgets/team-project/dashboard-overall-progress/index.ts b/src/widgets/team-project/dashboard-overall-progress/index.ts
new file mode 100644
index 0000000..818d36c
--- /dev/null
+++ b/src/widgets/team-project/dashboard-overall-progress/index.ts
@@ -0,0 +1 @@
+export { default as OverallProgress } from './ui/OverallProgress';
diff --git a/src/widgets/team-project/dashboard-overall-progress/ui/OverallProgress.tsx b/src/widgets/team-project/dashboard-overall-progress/ui/OverallProgress.tsx
new file mode 100644
index 0000000..d2c9a58
--- /dev/null
+++ b/src/widgets/team-project/dashboard-overall-progress/ui/OverallProgress.tsx
@@ -0,0 +1,65 @@
+import { getMockTasksByWorkspaceId } from '@/entities/task';
+import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
+import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
+
+function toPrecisePercentage(value: number, total: number) {
+ if (total === 0) {
+ return 0;
+ }
+
+ return Math.round((value / total) * 1000) / 10;
+}
+
+export default function OverallProgress({
+ workspaceId,
+ size = 'md',
+}: {
+ workspaceId: string;
+ size?: WidgetSize;
+}) {
+ const tasks = getMockTasksByWorkspaceId(workspaceId, 'team-workspace');
+ const doneCount = tasks.filter((task) => task.status === 'done').length;
+ const totalCount = tasks.length;
+ const progressRate = toPrecisePercentage(doneCount, totalCount);
+ const isCompact = size === 'sm';
+
+ return (
+
+ 차트}
+ />
+
+
+
+
+ {doneCount} / {totalCount}
+
+
+
+
+
+
+ {progressRate}% 달성
+
+
+
+ );
+}
diff --git a/src/widgets/team-project/dashboard-work-summary/index.ts b/src/widgets/team-project/dashboard-work-summary/index.ts
new file mode 100644
index 0000000..cedc604
--- /dev/null
+++ b/src/widgets/team-project/dashboard-work-summary/index.ts
@@ -0,0 +1 @@
+export { default as WorkSummary } from './ui/WorkSummary';
diff --git a/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx b/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx
new file mode 100644
index 0000000..be332b8
--- /dev/null
+++ b/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx
@@ -0,0 +1,100 @@
+import { getMockTasksByWorkspaceId, type TaskStatus } from '@/entities/task';
+import { getMockWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member';
+import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
+
+const cardMeta: Record<
+ 'total' | 'done' | 'in-progress' | 'members',
+ {
+ label: string;
+ valueClassName: string;
+ }
+> = {
+ total: {
+ label: '전체 업무',
+ valueClassName: 'text-[#4f46e5]',
+ },
+ done: {
+ label: '완료된 업무',
+ valueClassName: 'text-[#16a34a]',
+ },
+ 'in-progress': {
+ label: '진행중',
+ valueClassName: 'text-[#eb7a00]',
+ },
+ members: {
+ label: '팀 멤버',
+ valueClassName: 'text-[#8b3dff]',
+ },
+};
+
+function countByStatus(tasks: ReturnType, status: TaskStatus) {
+ return tasks.filter((task) => task.status === status).length;
+}
+
+function SummaryCard({
+ label,
+ value,
+ valueClassName,
+}: {
+ label: string;
+ value: number;
+ valueClassName: string;
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
+
+export default function WorkSummary({
+ workspaceId,
+ size = 'md',
+}: {
+ workspaceId: string;
+ size?: WidgetSize;
+}) {
+ const tasks = getMockTasksByWorkspaceId(workspaceId, 'team-workspace');
+ const members = getMockWorkspaceMembersByWorkspaceId(workspaceId);
+ const totalCount = tasks.length;
+ const doneCount = countByStatus(tasks, 'done');
+ const inProgressCount = countByStatus(tasks, 'in-progress');
+ const completionRate = totalCount === 0 ? 0 : Math.round((doneCount / totalCount) * 100);
+
+ const cards = [
+ {
+ key: 'total' as const,
+ value: totalCount,
+ },
+ {
+ key: 'done' as const,
+ value: doneCount,
+ },
+ {
+ key: 'in-progress' as const,
+ value: inProgressCount,
+ },
+ {
+ key: 'members' as const,
+ value: members.length,
+ },
+ ];
+
+ return (
+
+ {cards.map(({ key, value }) => (
+
+ ))}
+
+ );
+}
From 9b5610ca12fb627ddbce40e16ba8d3066bbbaccb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=A7=80=EC=9B=85?=
Date: Mon, 13 Jul 2026 19:42:58 +0900
Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?=
=?UTF-8?q?=EC=98=81=20=EB=B0=8F=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?=
=?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EB=B0=B0=EC=B9=98=20=EB=B3=B4=EC=99=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../model/create-progress-chart-summary.ts | 2 +-
.../model/mock-workspace-members.ts | 18 +++++++-
.../dashboard/config/template-widgets.ts | 5 +++
.../validate-template-widget-layouts.ts | 43 +++++++++++++++++++
src/views/dashboard/config/widget-catalog.tsx | 8 ++--
.../ui/OverallProgress.tsx | 19 +++-----
.../dashboard-work-summary/ui/WorkSummary.tsx | 20 +++------
7 files changed, 81 insertions(+), 34 deletions(-)
create mode 100644 src/views/dashboard/config/validate-template-widget-layouts.ts
diff --git a/src/entities/progress-chart/model/create-progress-chart-summary.ts b/src/entities/progress-chart/model/create-progress-chart-summary.ts
index 6d9406c..45d75cc 100644
--- a/src/entities/progress-chart/model/create-progress-chart-summary.ts
+++ b/src/entities/progress-chart/model/create-progress-chart-summary.ts
@@ -26,7 +26,7 @@ function toPercentage(value: number, total: number) {
return 0;
}
- return Math.round((value / total) * 100);
+ return Math.round((value / total) * 1000) / 10;
}
function countByStatus(tasks: Task[], status: TaskStatus) {
diff --git a/src/entities/workspace-member/model/mock-workspace-members.ts b/src/entities/workspace-member/model/mock-workspace-members.ts
index 3ca2556..c01a683 100644
--- a/src/entities/workspace-member/model/mock-workspace-members.ts
+++ b/src/entities/workspace-member/model/mock-workspace-members.ts
@@ -176,7 +176,21 @@ const workspaceMemberAliasById: Record = {
[STORE_OPERATION_WORKSPACE_UUID]: 'store-workspace',
};
-export function getMockWorkspaceMembersByWorkspaceId(workspaceId: string): WorkspaceMember[] {
- const resolvedWorkspaceId = workspaceMemberAliasById[workspaceId] ?? workspaceId;
+function resolveWorkspaceMemberKey(workspaceId: string, fallbackWorkspaceId?: string) {
+ return workspaceMemberAliasById[workspaceId] ?? workspaceId ?? fallbackWorkspaceId;
+}
+
+export function getMockWorkspaceMembersByWorkspaceId(
+ workspaceId: string,
+ fallbackWorkspaceId?: string,
+): WorkspaceMember[] {
+ const resolvedWorkspaceId = resolveWorkspaceMemberKey(workspaceId, fallbackWorkspaceId);
+
+ if (!mockWorkspaceMembers.some((member) => member.workspaceId === resolvedWorkspaceId)) {
+ return fallbackWorkspaceId
+ ? mockWorkspaceMembers.filter((member) => member.workspaceId === fallbackWorkspaceId)
+ : [];
+ }
+
return mockWorkspaceMembers.filter((member) => member.workspaceId === resolvedWorkspaceId);
}
diff --git a/src/views/dashboard/config/template-widgets.ts b/src/views/dashboard/config/template-widgets.ts
index 171fc67..49c3b05 100644
--- a/src/views/dashboard/config/template-widgets.ts
+++ b/src/views/dashboard/config/template-widgets.ts
@@ -5,6 +5,7 @@
import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types';
import type { WidgetId } from './widget-catalog';
+import { validateTemplateWidgetLayouts } from './validate-template-widget-layouts';
export const TEMPLATE_WIDGETS: Record = {
// 사이드 프로젝트 — 개발 진척 전반
@@ -32,3 +33,7 @@ export const TEMPLATE_WIDGETS: Record = {
'recent-resources',
],
};
+
+if (process.env.NODE_ENV !== 'production') {
+ validateTemplateWidgetLayouts(TEMPLATE_WIDGETS);
+}
diff --git a/src/views/dashboard/config/validate-template-widget-layouts.ts b/src/views/dashboard/config/validate-template-widget-layouts.ts
new file mode 100644
index 0000000..664fd1f
--- /dev/null
+++ b/src/views/dashboard/config/validate-template-widget-layouts.ts
@@ -0,0 +1,43 @@
+import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types';
+
+import { WIDGET_CATALOG, type WidgetId } from './widget-catalog';
+
+interface LayoutRect {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+function isOverlapping(left: LayoutRect, right: LayoutRect) {
+ return (
+ left.x < right.x + right.w &&
+ left.x + left.w > right.x &&
+ left.y < right.y + right.h &&
+ left.y + left.h > right.y
+ );
+}
+
+/**
+ * 템플릿별 "기본 추가 배치"가 겹치지 않는지 개발 시점에 바로 검증한다.
+ * 저장된 레이아웃은 사용자별로 달라질 수 있지만, 기본 카탈로그 좌표는 템플릿 내부에서 충돌하면 안 된다.
+ */
+export function validateTemplateWidgetLayouts(
+ templateWidgets: Record,
+) {
+ Object.entries(templateWidgets).forEach(([purpose, widgetIds]) => {
+ widgetIds.forEach((widgetId, index) => {
+ const currentLayout = WIDGET_CATALOG[widgetId].layout;
+
+ widgetIds.slice(index + 1).forEach((otherWidgetId) => {
+ const otherLayout = WIDGET_CATALOG[otherWidgetId].layout;
+
+ if (isOverlapping(currentLayout, otherLayout)) {
+ throw new Error(
+ `기본 위젯 배치가 겹칩니다: ${purpose} template - ${widgetId} / ${otherWidgetId}`,
+ );
+ }
+ });
+ });
+ });
+}
diff --git a/src/views/dashboard/config/widget-catalog.tsx b/src/views/dashboard/config/widget-catalog.tsx
index c4149fd..e3de43c 100644
--- a/src/views/dashboard/config/widget-catalog.tsx
+++ b/src/views/dashboard/config/widget-catalog.tsx
@@ -46,7 +46,7 @@ export const WIDGET_CATALOG = {
render: (size) => ,
},
'recent-notices': {
- layout: { i: 'recent-notices', x: 6, y: 5, w: 6, h: 5, minW: 2, minH: 3 },
+ layout: { i: 'recent-notices', x: 0, y: 5, w: 6, h: 5, minW: 2, minH: 3 },
title: '최근 공지',
render: (size) => ,
},
@@ -68,17 +68,17 @@ export const WIDGET_CATALOG = {
render: (size) => ,
},
'overall-progress': {
- layout: { i: 'overall-progress', x: 6, y: 10, w: 6, h: 5, minW: 3, minH: 4 },
+ layout: { i: 'overall-progress', x: 9, y: 10, w: 3, h: 5, minW: 3, minH: 4 },
title: '전체 진행률',
render: (size, { workspaceId }) => ,
},
'work-summary': {
- layout: { i: 'work-summary', x: 0, y: 0, w: 12, h: 5, minW: 6, minH: 4 },
+ layout: { i: 'work-summary', x: 0, y: 15, w: 12, h: 5, minW: 6, minH: 4 },
title: '업무 요약',
render: (size, { workspaceId }) => ,
},
calendar: {
- layout: { i: 'calendar', x: 6, y: 14, w: 6, h: 8, minW: 4, minH: 6 },
+ layout: { i: 'calendar', x: 6, y: 20, w: 6, h: 8, minW: 4, minH: 6 },
title: '캘린더',
render: (size) => ,
},
diff --git a/src/widgets/team-project/dashboard-overall-progress/ui/OverallProgress.tsx b/src/widgets/team-project/dashboard-overall-progress/ui/OverallProgress.tsx
index d2c9a58..8d6f6f9 100644
--- a/src/widgets/team-project/dashboard-overall-progress/ui/OverallProgress.tsx
+++ b/src/widgets/team-project/dashboard-overall-progress/ui/OverallProgress.tsx
@@ -1,15 +1,8 @@
+import { createProgressChartSummary } from '@/entities/progress-chart';
import { getMockTasksByWorkspaceId } from '@/entities/task';
import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
-function toPrecisePercentage(value: number, total: number) {
- if (total === 0) {
- return 0;
- }
-
- return Math.round((value / total) * 1000) / 10;
-}
-
export default function OverallProgress({
workspaceId,
size = 'md',
@@ -18,9 +11,7 @@ export default function OverallProgress({
size?: WidgetSize;
}) {
const tasks = getMockTasksByWorkspaceId(workspaceId, 'team-workspace');
- const doneCount = tasks.filter((task) => task.status === 'done').length;
- const totalCount = tasks.length;
- const progressRate = toPrecisePercentage(doneCount, totalCount);
+ const summary = createProgressChartSummary(tasks);
const isCompact = size === 'sm';
return (
@@ -39,7 +30,7 @@ export default function OverallProgress({
isCompact ? 'text-[17px]' : 'text-[22px]'
}`}
>
- {doneCount} / {totalCount}
+ {summary.doneTaskCount} / {summary.totalTaskCount}
@@ -48,7 +39,7 @@ export default function OverallProgress({
>
@@ -57,7 +48,7 @@ export default function OverallProgress({
isCompact ? 'mt-5 text-[15px]' : 'mt-6 text-[18px]'
}`}
>
- {progressRate}% 달성
+ {summary.overallProgressRate}% 달성
diff --git a/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx b/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx
index be332b8..2183ffe 100644
--- a/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx
+++ b/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx
@@ -1,4 +1,5 @@
-import { getMockTasksByWorkspaceId, type TaskStatus } from '@/entities/task';
+import { createProgressChartSummary } from '@/entities/progress-chart';
+import { getMockTasksByWorkspaceId } from '@/entities/task';
import { getMockWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member';
import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
@@ -27,10 +28,6 @@ const cardMeta: Record<
},
};
-function countByStatus(tasks: ReturnType, status: TaskStatus) {
- return tasks.filter((task) => task.status === status).length;
-}
-
function SummaryCard({
label,
value,
@@ -60,24 +57,21 @@ export default function WorkSummary({
size?: WidgetSize;
}) {
const tasks = getMockTasksByWorkspaceId(workspaceId, 'team-workspace');
- const members = getMockWorkspaceMembersByWorkspaceId(workspaceId);
- const totalCount = tasks.length;
- const doneCount = countByStatus(tasks, 'done');
- const inProgressCount = countByStatus(tasks, 'in-progress');
- const completionRate = totalCount === 0 ? 0 : Math.round((doneCount / totalCount) * 100);
+ const summary = createProgressChartSummary(tasks);
+ const members = getMockWorkspaceMembersByWorkspaceId(workspaceId, 'team-workspace');
const cards = [
{
key: 'total' as const,
- value: totalCount,
+ value: summary.totalTaskCount,
},
{
key: 'done' as const,
- value: doneCount,
+ value: summary.doneTaskCount,
},
{
key: 'in-progress' as const,
- value: inProgressCount,
+ value: summary.inProgressTaskCount,
},
{
key: 'members' as const,
From 73aeea069b57004dda5bf42a728262da7883131d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=A7=80=EC=9B=85?=
Date: Mon, 13 Jul 2026 19:52:56 +0900
Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=82=AC=EC=9D=B4=EB=93=9C?=
=?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=9C=84=EC=A0=AF=20?=
=?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EB=B0=B0=EC=B9=98=20=EC=B6=A9=EB=8F=8C=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/views/dashboard/config/widget-catalog.tsx | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/views/dashboard/config/widget-catalog.tsx b/src/views/dashboard/config/widget-catalog.tsx
index e3de43c..46ff652 100644
--- a/src/views/dashboard/config/widget-catalog.tsx
+++ b/src/views/dashboard/config/widget-catalog.tsx
@@ -26,22 +26,22 @@ export const WIDGET_CATALOG = {
render: () => ,
},
'my-tasks': {
- layout: { i: 'my-tasks', x: 0, y: 0, w: 6, h: 5, minW: 2, minH: 3 },
+ layout: { i: 'my-tasks', x: 0, y: 4, 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 },
+ layout: { i: 'velocity', x: 6, y: 4, 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 },
+ layout: { i: 'backlog', x: 0, y: 9, 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 },
+ layout: { i: 'recent-notes', x: 0, y: 14, w: 6, h: 5, minW: 2, minH: 3 },
title: '최근 회의록',
render: (size) => ,
},
@@ -63,7 +63,7 @@ export const WIDGET_CATALOG = {
),
},
'today-schedule': {
- layout: { i: 'today-schedule', x: 0, y: 10, w: 6, h: 4, minW: 2, minH: 3 },
+ layout: { i: 'today-schedule', x: 6, y: 9, w: 6, h: 4, minW: 2, minH: 3 },
title: '오늘 일정',
render: (size) => ,
},
@@ -78,7 +78,7 @@ export const WIDGET_CATALOG = {
render: (size, { workspaceId }) => ,
},
calendar: {
- layout: { i: 'calendar', x: 6, y: 20, w: 6, h: 8, minW: 4, minH: 6 },
+ layout: { i: 'calendar', x: 6, y: 13, w: 6, h: 8, minW: 4, minH: 6 },
title: '캘린더',
render: (size) => ,
},
From 9d54b57ad49e84fed958326133e26991b992e489 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=A7=80=EC=9B=85?=
Date: Tue, 14 Jul 2026 09:12:47 +0900
Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=ED=85=9C=ED=94=8C=EB=A6=BF?=
=?UTF-8?q?=EB=B3=84=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=9C=84?=
=?UTF-8?q?=EC=A0=AF=20=EA=B8=B0=EB=B3=B8=20=EB=A0=88=EC=9D=B4=EC=95=84?=
=?UTF-8?q?=EC=9B=83=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../config/template-widget-layouts.ts | 57 +++++++++++++++++++
.../dashboard/config/template-widgets.ts | 45 ++++-----------
.../validate-template-widget-layouts.ts | 21 +++++--
src/views/dashboard/ui/DashboardView.tsx | 12 ++--
4 files changed, 90 insertions(+), 45 deletions(-)
create mode 100644 src/views/dashboard/config/template-widget-layouts.ts
diff --git a/src/views/dashboard/config/template-widget-layouts.ts b/src/views/dashboard/config/template-widget-layouts.ts
new file mode 100644
index 0000000..6649189
--- /dev/null
+++ b/src/views/dashboard/config/template-widget-layouts.ts
@@ -0,0 +1,57 @@
+import type { LayoutItem } from 'react-grid-layout';
+
+import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types';
+
+import type { WidgetId } from './widget-catalog';
+
+type TemplateWidgetLayoutMap = Partial>;
+
+function getWidgetIds(layouts: TemplateWidgetLayoutMap) {
+ return Object.keys(layouts) as WidgetId[];
+}
+
+const SIDE_PROJECT_WIDGET_LAYOUTS = {
+ 'sprint-summary': { i: 'sprint-summary', x: 0, y: 0, w: 12, h: 4 },
+ 'my-tasks': { i: 'my-tasks', x: 0, y: 4, w: 6, h: 5 },
+ velocity: { i: 'velocity', x: 6, y: 4, w: 6, h: 5 },
+ backlog: { i: 'backlog', x: 0, y: 9, w: 6, h: 5 },
+ 'today-schedule': { i: 'today-schedule', x: 6, y: 9, w: 6, h: 4 },
+ 'recent-notes': { i: 'recent-notes', x: 0, y: 14, w: 6, h: 5 },
+ calendar: { i: 'calendar', x: 6, y: 13, w: 6, h: 8 },
+} satisfies TemplateWidgetLayoutMap;
+
+const STORE_OPERATION_WIDGET_LAYOUTS = {
+ 'recent-notices': { i: 'recent-notices', x: 0, y: 0, w: 6, h: 5 },
+ 'recent-resources': { i: 'recent-resources', x: 6, y: 0, w: 6, h: 5 },
+ 'work-schedule': { i: 'work-schedule', x: 0, y: 5, w: 6, h: 5 },
+ calendar: { i: 'calendar', x: 6, y: 5, w: 6, h: 8 },
+} satisfies TemplateWidgetLayoutMap;
+
+const TEAM_PROJECT_WIDGET_LAYOUTS = {
+ 'work-summary': { i: 'work-summary', x: 0, y: 0, w: 12, h: 5 },
+ 'my-tasks': { i: 'my-tasks', x: 0, y: 5, w: 6, h: 5 },
+ 'recent-notices': { i: 'recent-notices', x: 6, y: 5, w: 6, h: 5 },
+ 'recent-notes': { i: 'recent-notes', x: 0, y: 10, w: 6, h: 5 },
+ 'recent-resources': { i: 'recent-resources', x: 6, y: 10, w: 6, h: 5 },
+ 'overall-progress': { i: 'overall-progress', x: 0, y: 15, w: 3, h: 5 },
+ calendar: { i: 'calendar', x: 3, y: 15, w: 9, h: 8 },
+} satisfies TemplateWidgetLayoutMap;
+
+// 같은 위젯이라도 템플릿별로 함께 놓이는 조합이 다르기 때문에,
+// "추가 시 기본 위치"는 purpose별 설정으로 분리해서 관리한다.
+export const TEMPLATE_WIDGET_LAYOUTS = {
+ 'side-project': SIDE_PROJECT_WIDGET_LAYOUTS,
+ 'store-operation': STORE_OPERATION_WIDGET_LAYOUTS,
+ 'team-project': TEAM_PROJECT_WIDGET_LAYOUTS,
+} satisfies Record;
+
+export const TEMPLATE_WIDGETS: Record = {
+ 'side-project': getWidgetIds(SIDE_PROJECT_WIDGET_LAYOUTS),
+ 'store-operation': getWidgetIds(STORE_OPERATION_WIDGET_LAYOUTS),
+ 'team-project': getWidgetIds(TEAM_PROJECT_WIDGET_LAYOUTS),
+};
+
+export function getTemplateWidgetLayout(purpose: WorkspacePurpose, widgetId: WidgetId) {
+ const layouts = TEMPLATE_WIDGET_LAYOUTS[purpose] as TemplateWidgetLayoutMap;
+ return layouts[widgetId];
+}
diff --git a/src/views/dashboard/config/template-widgets.ts b/src/views/dashboard/config/template-widgets.ts
index 49c3b05..3745e20 100644
--- a/src/views/dashboard/config/template-widgets.ts
+++ b/src/views/dashboard/config/template-widgets.ts
@@ -1,39 +1,14 @@
-// 템플릿(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';
+// 템플릿(purpose)별 대시보드 설정 public entry.
+// 허용 위젯 목록과 기본 좌표는 같은 소스에서 관리해, 특정 템플릿 안에서
+// 함께 추가 가능한 위젯들이 서로 겹치지 않도록 유지한다.
import { validateTemplateWidgetLayouts } from './validate-template-widget-layouts';
-
-export const TEMPLATE_WIDGETS: Record = {
- // 사이드 프로젝트 — 개발 진척 전반
- 'side-project': [
- 'sprint-summary',
- 'my-tasks',
- 'velocity',
- 'backlog',
- 'recent-notes',
- 'today-schedule',
- 'calendar',
- ],
- // TODO: 매장운영 템플릿에 들어가는 위젯 생성, 추가, 수정
- // 매장운영 — 일정/업무/캘린더/회의록 (개발 지표 제외)
- 'store-operation': ['work-schedule', 'calendar', 'recent-notices', 'recent-resources'],
- // TODO: 팀플 템플릿에 들어가는 위젯 생성, 추가, 수정
- // 팀 프로젝트 — 진척·협업
- 'team-project': [
- 'my-tasks',
- 'recent-notes',
- 'overall-progress',
- 'work-summary',
- 'calendar',
- 'recent-notices',
- 'recent-resources',
- ],
-};
+export {
+ TEMPLATE_WIDGET_LAYOUTS,
+ TEMPLATE_WIDGETS,
+ getTemplateWidgetLayout,
+} from './template-widget-layouts';
+import { TEMPLATE_WIDGET_LAYOUTS } from './template-widget-layouts';
if (process.env.NODE_ENV !== 'production') {
- validateTemplateWidgetLayouts(TEMPLATE_WIDGETS);
+ validateTemplateWidgetLayouts(TEMPLATE_WIDGET_LAYOUTS);
}
diff --git a/src/views/dashboard/config/validate-template-widget-layouts.ts b/src/views/dashboard/config/validate-template-widget-layouts.ts
index 664fd1f..c2065d4 100644
--- a/src/views/dashboard/config/validate-template-widget-layouts.ts
+++ b/src/views/dashboard/config/validate-template-widget-layouts.ts
@@ -1,6 +1,8 @@
+import type { LayoutItem } from 'react-grid-layout';
+
import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types';
-import { WIDGET_CATALOG, type WidgetId } from './widget-catalog';
+import type { WidgetId } from './widget-catalog';
interface LayoutRect {
x: number;
@@ -20,17 +22,24 @@ function isOverlapping(left: LayoutRect, right: LayoutRect) {
/**
* 템플릿별 "기본 추가 배치"가 겹치지 않는지 개발 시점에 바로 검증한다.
- * 저장된 레이아웃은 사용자별로 달라질 수 있지만, 기본 카탈로그 좌표는 템플릿 내부에서 충돌하면 안 된다.
+ * 저장된 레이아웃은 사용자별로 달라질 수 있지만, 같은 템플릿에서 함께 추가 가능한
+ * 기본 좌표는 서로 충돌하면 안 된다.
*/
export function validateTemplateWidgetLayouts(
- templateWidgets: Record,
+ templateWidgetLayouts: Record>>,
) {
- Object.entries(templateWidgets).forEach(([purpose, widgetIds]) => {
+ Object.entries(templateWidgetLayouts).forEach(([purpose, layouts]) => {
+ const widgetIds = Object.keys(layouts) as WidgetId[];
+
widgetIds.forEach((widgetId, index) => {
- const currentLayout = WIDGET_CATALOG[widgetId].layout;
+ const currentLayout = layouts[widgetId];
+
+ if (!currentLayout) return;
widgetIds.slice(index + 1).forEach((otherWidgetId) => {
- const otherLayout = WIDGET_CATALOG[otherWidgetId].layout;
+ const otherLayout = layouts[otherWidgetId];
+
+ if (!otherLayout) return;
if (isOverlapping(currentLayout, otherLayout)) {
throw new Error(
diff --git a/src/views/dashboard/ui/DashboardView.tsx b/src/views/dashboard/ui/DashboardView.tsx
index 3482584..e527984 100644
--- a/src/views/dashboard/ui/DashboardView.tsx
+++ b/src/views/dashboard/ui/DashboardView.tsx
@@ -1,7 +1,7 @@
// 대시보드 — 카탈로그(위젯 전체)와 훅이 관리하는 배치(layout)를 id로 조인해 그린다.
// · 배치 상태·추가/삭제·영속화 → edit-layout 훅 (레이아웃은 user_id+workspace_id로 조회/저장)
-// · 위젯 렌더 + 추가 기본 배치 → WIDGET_CATALOG (전역)
-// · 추가 메뉴 스코프 → TEMPLATE_WIDGETS[purpose] (템플릿별 허용 위젯)
+// · 위젯 렌더 → WIDGET_CATALOG (전역)
+// · 추가 메뉴/기본 배치 → 템플릿별 widget config (purpose 기준)
// · AppShell(사이드바/탑바)·폰트 → 상위 워크스페이스 layout 담당
// 빈 상태로 시작하고, 편집 모드에서 이 템플릿이 허용하는 위젯을 추가해 구성한다.
'use client';
@@ -14,7 +14,7 @@ import {
import type { DashboardLayoutState } from '@/entities/dashboard-layout/model/dashboard-layout.types';
import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types';
-import { TEMPLATE_WIDGETS } from '../config/template-widgets';
+import { getTemplateWidgetLayout, TEMPLATE_WIDGETS } from '../config/template-widgets';
import { WIDGET_CATALOG, type WidgetId } from '../config/widget-catalog';
import AddWidgetBar from './AddWidgetBar';
import DashboardGrid from './DashboardGrid';
@@ -50,7 +50,11 @@ export default function DashboardView({
const handleAdd = (id: string) => {
if (!(id in WIDGET_CATALOG)) return;
- addWidget(WIDGET_CATALOG[id as WidgetId].layout);
+
+ const defaultLayout = getTemplateWidgetLayout(purpose, id as WidgetId);
+
+ if (!defaultLayout) return;
+ addWidget(defaultLayout);
};
return (