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..45d75cc --- /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) * 1000) / 10; +} + +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..c01a683 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,31 @@ export const mockWorkspaceMembers: WorkspaceMember[] = [ }, ]; -export function getMockWorkspaceMembersByWorkspaceId(workspaceId: string): WorkspaceMember[] { - return mockWorkspaceMembers.filter((member) => member.workspaceId === workspaceId); +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', +}; + +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/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-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 b44c28d..3745e20 100644 --- a/src/views/dashboard/config/template-widgets.ts +++ b/src/views/dashboard/config/template-widgets.ts @@ -1,26 +1,14 @@ -// 템플릿(purpose)별로 대시보드 편집 모드에서 "추가"할 수 있는 위젯 id 목록. -// WIDGET_CATALOG(전역)의 부분집합이며, 렌더·기본배치는 카탈로그가 담당한다. -// 값 타입이 WidgetId라 카탈로그에 없는 id를 적으면 컴파일 에러가 난다. -// 이 목록은 "추가 메뉴 스코프"만 정한다 — 레이아웃 조회/저장(user_id+workspace_id)과는 무관. -import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types'; +// 템플릿(purpose)별 대시보드 설정 public entry. +// 허용 위젯 목록과 기본 좌표는 같은 소스에서 관리해, 특정 템플릿 안에서 +// 함께 추가 가능한 위젯들이 서로 겹치지 않도록 유지한다. +import { validateTemplateWidgetLayouts } from './validate-template-widget-layouts'; +export { + TEMPLATE_WIDGET_LAYOUTS, + TEMPLATE_WIDGETS, + getTemplateWidgetLayout, +} from './template-widget-layouts'; +import { TEMPLATE_WIDGET_LAYOUTS } from './template-widget-layouts'; -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': ['work-schedule', 'calendar', 'recent-notices', 'recent-resources'], - // TODO: 팀플 템플릿에 들어가는 위젯 생성, 추가, 수정 - // 팀 프로젝트 — 진척·협업 - 'team-project': ['my-tasks', 'recent-notes', 'calendar', 'recent-notices', 'recent-resources'], -}; +if (process.env.NODE_ENV !== 'production') { + 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 new file mode 100644 index 0000000..c2065d4 --- /dev/null +++ b/src/views/dashboard/config/validate-template-widget-layouts.ts @@ -0,0 +1,52 @@ +import type { LayoutItem } from 'react-grid-layout'; + +import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types'; + +import 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( + templateWidgetLayouts: Record>>, +) { + Object.entries(templateWidgetLayouts).forEach(([purpose, layouts]) => { + const widgetIds = Object.keys(layouts) as WidgetId[]; + + widgetIds.forEach((widgetId, index) => { + const currentLayout = layouts[widgetId]; + + if (!currentLayout) return; + + widgetIds.slice(index + 1).forEach((otherWidgetId) => { + const otherLayout = layouts[otherWidgetId]; + + if (!otherLayout) return; + + 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 61690b7..46ff652 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)와 일치해야 한다. @@ -24,27 +26,27 @@ 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) => , }, '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) => , }, @@ -61,12 +63,22 @@ 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) => , }, + 'overall-progress': { + 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: 15, 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: 13, w: 6, h: 8, minW: 4, minH: 6 }, title: '캘린더', render: (size) => , }, 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 ( 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..8d6f6f9 --- /dev/null +++ b/src/widgets/team-project/dashboard-overall-progress/ui/OverallProgress.tsx @@ -0,0 +1,56 @@ +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'; + +export default function OverallProgress({ + workspaceId, + size = 'md', +}: { + workspaceId: string; + size?: WidgetSize; +}) { + const tasks = getMockTasksByWorkspaceId(workspaceId, 'team-workspace'); + const summary = createProgressChartSummary(tasks); + const isCompact = size === 'sm'; + + return ( + + 차트} + /> +
+
+
+

완료

+
+

+ {summary.doneTaskCount} / {summary.totalTaskCount} +

+
+ +
+
+
+ +

+ {summary.overallProgressRate}% 달성 +

+
+ + ); +} 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..2183ffe --- /dev/null +++ b/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx @@ -0,0 +1,94 @@ +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'; + +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 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 summary = createProgressChartSummary(tasks); + const members = getMockWorkspaceMembersByWorkspaceId(workspaceId, 'team-workspace'); + + const cards = [ + { + key: 'total' as const, + value: summary.totalTaskCount, + }, + { + key: 'done' as const, + value: summary.doneTaskCount, + }, + { + key: 'in-progress' as const, + value: summary.inProgressTaskCount, + }, + { + key: 'members' as const, + value: members.length, + }, + ]; + + return ( +
+ {cards.map(({ key, value }) => ( + + ))} +
+ ); +}