diff --git a/.gitignore b/.gitignore index 33f14f9..32939d8 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,6 @@ next-env.d.ts # agent files (개인 작업용) AGENTS.md +CLAUDE.md .agents/ .claude/ diff --git a/src/app/workspaces/[workspaceId]/dashboard/page.tsx b/src/app/workspaces/[workspaceId]/dashboard/page.tsx new file mode 100644 index 0000000..3332107 --- /dev/null +++ b/src/app/workspaces/[workspaceId]/dashboard/page.tsx @@ -0,0 +1,26 @@ +// 워크스페이스 대시보드 라우트 — 레이아웃은 서버(RSC)에서 조회해 initialLayout으로 주입한다. +// (레이아웃 조회 키: user_id + workspace_id + page_type / 저장분 없으면 빈 대시보드로 시작) +// purpose는 추가 가능한 위젯을 템플릿별로 거르는 데만 쓰인다. +import { getDashboardLayout } from '@/entities/dashboard-layout'; +import { DashboardView } from '@/views/dashboard'; +import { getMockWorkspaceById } from '@/entities/workspace'; + +interface DashboardPageProps { + params: Promise<{ workspaceId: string }>; +} + +export default async function DashboardPage({ params }: DashboardPageProps) { + const { workspaceId } = await params; + + const workspace = getMockWorkspaceById(workspaceId)!; + + const initialLayout = await getDashboardLayout(workspaceId, 'dashboard'); + return ( + + ); +} diff --git a/src/app/workspaces/[workspaceId]/page.tsx b/src/app/workspaces/[workspaceId]/page.tsx index 2d00c26..770de6c 100644 --- a/src/app/workspaces/[workspaceId]/page.tsx +++ b/src/app/workspaces/[workspaceId]/page.tsx @@ -7,9 +7,7 @@ interface WorkspaceHomePageProps { }>; } -export default async function WorkspaceHomePage({ - params, -}: WorkspaceHomePageProps) { +export default async function WorkspaceHomePage({ params }: WorkspaceHomePageProps) { const { workspaceId } = await params; const workspace = getMockWorkspaceById(workspaceId); diff --git a/src/entities/dashboard-layout/api/get-dashboard-layout.ts b/src/entities/dashboard-layout/api/get-dashboard-layout.ts new file mode 100644 index 0000000..d204502 --- /dev/null +++ b/src/entities/dashboard-layout/api/get-dashboard-layout.ts @@ -0,0 +1,18 @@ +// 대시보드 레이아웃 조회 — DB 연동 자리. +// 저장분이 없으면(신규) 빈 레이아웃으로 시작한다 — 템플릿 기반 기본값/폴백은 두지 않는다. + +import type { DashboardLayoutState } from '../model/dashboard-layout.types'; + +// TODO: DB 연동 — WORKSPACE_LAYOUTS에서 (workspace_id, user_id(세션), page_type) 기준 select +export async function getDashboardLayout( + workspaceId: string, + pageType: string, +): Promise { + void workspaceId; + void pageType; + // 임시 목 저장분 — DB의 layout jsonb를 흉내낸다. 위치(i,x,y,w,h)만 담고, + // 제약(minW/minH)은 저장하지 않는다(렌더 시 카탈로그에서 머지됨). + return { + layout: [], + }; +} diff --git a/src/entities/dashboard-layout/api/save-dashboard-layout.ts b/src/entities/dashboard-layout/api/save-dashboard-layout.ts new file mode 100644 index 0000000..e411796 --- /dev/null +++ b/src/entities/dashboard-layout/api/save-dashboard-layout.ts @@ -0,0 +1,16 @@ +// 대시보드 레이아웃 저장 — DB 연동 자리(서버액션). +// layout jsonb 한 행 = DashboardLayoutState 통째. 드래그 중 잦은 호출은 debounce 필요. +'use server'; + +import type { DashboardLayoutState } from '../model/dashboard-layout.types'; + +// TODO: DB 연동 — WORKSPACE_LAYOUTS upsert (workspace_id, user_id(세션), page_type, layout) +export async function saveDashboardLayout( + workspaceId: string, + pageType: string, + state: DashboardLayoutState, +): Promise { + void workspaceId; + void pageType; + void state; +} diff --git a/src/entities/dashboard-layout/index.ts b/src/entities/dashboard-layout/index.ts new file mode 100644 index 0000000..cb26738 --- /dev/null +++ b/src/entities/dashboard-layout/index.ts @@ -0,0 +1,4 @@ +// dashboard-layout 엔티티의 Public API — 개인 대시보드 레이아웃 조회/저장. +export type { DashboardLayoutState } from './model/dashboard-layout.types'; +export { getDashboardLayout } from './api/get-dashboard-layout'; +export { saveDashboardLayout } from './api/save-dashboard-layout'; diff --git a/src/entities/dashboard-layout/model/dashboard-layout.types.ts b/src/entities/dashboard-layout/model/dashboard-layout.types.ts new file mode 100644 index 0000000..b26dca6 --- /dev/null +++ b/src/entities/dashboard-layout/model/dashboard-layout.types.ts @@ -0,0 +1,7 @@ +// 대시보드 레이아웃 저장 상태 타입 — DB(WORKSPACE_LAYOUTS)의 layout jsonb 한 행에 직렬화된다. +// 화면에 배치된 위젯 = layout. 배치되지 않은 위젯은 카탈로그에서 "추가"로 꺼낸다(별도 hidden 개념 없음). +import type { Layout } from 'react-grid-layout'; + +export interface DashboardLayoutState { + layout: Layout; +} diff --git a/src/entities/project-column/ui/ProjectColumn.tsx b/src/entities/project-column/ui/ProjectColumn.tsx index 279fc5f..02e81c6 100644 --- a/src/entities/project-column/ui/ProjectColumn.tsx +++ b/src/entities/project-column/ui/ProjectColumn.tsx @@ -52,9 +52,9 @@ export function ProjectColumn({
-

{column.title}

+

{column.title}

- {column.tasks.length} + {column.tasks.length}
{ event.preventDefault(); @@ -90,7 +91,7 @@ export function ProjectColumn({ ))} {dragOverIndex === column.tasks.length ? ( -
+
) : null}
diff --git a/src/entities/side-project/meeting-note/index.ts b/src/entities/side-project/meeting-note/index.ts new file mode 100644 index 0000000..85fa5ae --- /dev/null +++ b/src/entities/side-project/meeting-note/index.ts @@ -0,0 +1,3 @@ +// meeting-note 엔티티의 Public API (모델) +export { type MeetingNote } from './model/meeting-note.types'; +export { mockMeetingNotes } from './model/meeting-note.mock'; diff --git a/src/entities/side-project/meeting-note/model/meeting-note.mock.ts b/src/entities/side-project/meeting-note/model/meeting-note.mock.ts new file mode 100644 index 0000000..93b0f37 --- /dev/null +++ b/src/entities/side-project/meeting-note/model/meeting-note.mock.ts @@ -0,0 +1,29 @@ +// 회의록 목데이터 +import type { MeetingNote } from './meeting-note.types'; + +export const mockMeetingNotes: MeetingNote[] = [ + { + id: 'note-1', + title: 'Sprint 2 플래닝', + date: '2025-07-01', + summary: '스프린트 목표와 백로그 우선순위를 확정했습니다.', + }, + { + id: 'note-2', + title: 'Sprint 1 회고', + date: '2025-06-28', + summary: '지난 스프린트의 성과와 개선점을 논의했습니다.', + }, + { + id: 'note-3', + title: '디자인 시스템 논의', + date: '2025-06-24', + summary: '공통 컴포넌트와 디자인 토큰 구조를 정리했습니다.', + }, + { + id: 'note-4', + title: 'API 명세 리뷰', + date: '2025-06-20', + summary: '엔드포인트 규격과 에러 응답 형식을 검토했습니다.', + }, +]; diff --git a/src/entities/side-project/meeting-note/model/meeting-note.types.ts b/src/entities/side-project/meeting-note/model/meeting-note.types.ts new file mode 100644 index 0000000..0f30439 --- /dev/null +++ b/src/entities/side-project/meeting-note/model/meeting-note.types.ts @@ -0,0 +1,8 @@ +// 회의록(MeetingNote) 도메인 모델 +export interface MeetingNote { + id: string; + title: string; + date: string; + /** 본문 요약(미리보기) */ + summary: string; +} diff --git a/src/entities/side-project/schedule-event/index.ts b/src/entities/side-project/schedule-event/index.ts new file mode 100644 index 0000000..c9977ed --- /dev/null +++ b/src/entities/side-project/schedule-event/index.ts @@ -0,0 +1,8 @@ +// schedule-event 엔티티의 Public API (모델) +export { + SCHEDULE_TYPE_COLOR, + type ScheduleEvent, + type ScheduleEventType, + type CalendarMonth, +} from './model/schedule-event.types'; +export { mockTodaySchedule, mockCalendar } from './model/schedule-event.mock'; diff --git a/src/entities/side-project/schedule-event/model/schedule-event.mock.ts b/src/entities/side-project/schedule-event/model/schedule-event.mock.ts new file mode 100644 index 0000000..2cec3ca --- /dev/null +++ b/src/entities/side-project/schedule-event/model/schedule-event.mock.ts @@ -0,0 +1,18 @@ +// 오늘 일정·월간 캘린더 목데이터 +import type { CalendarMonth, ScheduleEvent } from './schedule-event.types'; + +export const mockTodaySchedule: ScheduleEvent[] = [ + { id: 'event-1', title: '디자인 리뷰 회의', time: '11:00', type: 'meeting' }, + { id: 'event-2', title: '팀 점심', time: '12:30', type: 'meeting' }, + { id: 'event-3', title: '스프린트 데일리', time: '14:00', type: 'meeting' }, + { id: 'event-4', title: '코드 리뷰', time: '15:00', type: 'meeting' }, + { id: 'event-5', title: 'API 명세 마감', time: '16:00', type: 'deadline' }, + { id: 'event-6', title: '회고 준비', time: '17:00', type: 'meeting' }, +]; + +export const mockCalendar: CalendarMonth = { + year: 2025, + month: 7, + today: 30, + eventDays: [2, 5, 8, 10, 14, 22, 28], +}; diff --git a/src/entities/side-project/schedule-event/model/schedule-event.types.ts b/src/entities/side-project/schedule-event/model/schedule-event.types.ts new file mode 100644 index 0000000..d770209 --- /dev/null +++ b/src/entities/side-project/schedule-event/model/schedule-event.types.ts @@ -0,0 +1,26 @@ +// 일정(ScheduleEvent)·캘린더 도메인 모델 + 유형 색상 +export type ScheduleEventType = 'meeting' | 'deadline'; + +/** 일정 유형별 액센트 바 색상 (회의=보라 brand-end, 마감=빨강) */ +export const SCHEDULE_TYPE_COLOR: Record = { + meeting: '#8e51ff', + deadline: '#fb2c36', +}; + +export interface ScheduleEvent { + id: string; + title: string; + time: string; + type: ScheduleEventType; +} + +/** 월간 캘린더 데이터 */ +export interface CalendarMonth { + year: number; + /** 1-12 */ + month: number; + /** 오늘 날짜(일). 해당 월이 아니면 null */ + today: number | null; + /** 이벤트 점이 표시될 날짜(일) */ + eventDays: number[]; +} diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts new file mode 100644 index 0000000..07f1ede --- /dev/null +++ b/src/entities/side-project/sprint/index.ts @@ -0,0 +1,4 @@ +// sprint 엔티티의 Public API — 스프린트 메타 + 벨로시티 +// 업무(Task)는 별도 슬라이스(@/entities/side-project/task)로 분리됨 +export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.types'; +export { currentSprint, sprintVelocity, SIDE_PROJECT_WORKSPACE_ID } from './model/sprint.mock'; diff --git a/src/entities/side-project/sprint/model/sprint.mock.ts b/src/entities/side-project/sprint/model/sprint.mock.ts new file mode 100644 index 0000000..6663c98 --- /dev/null +++ b/src/entities/side-project/sprint/model/sprint.mock.ts @@ -0,0 +1,23 @@ +// 스프린트 목데이터 — 현재 스프린트 메타 + 벨로시티 +// 업무(Task)는 여기서 소유하지 않는다 → task.mock.ts / task.selectors.ts 참고 +import type { Sprint, VelocityPoint } from './sprint.types'; + +// 사이드 프로젝트 데모 워크스페이스 id — 스프린트·업무 목데이터가 공유하는 소유 워크스페이스 +export const SIDE_PROJECT_WORKSPACE_ID = 'ws-side-project'; + +export const currentSprint: Sprint = { + id: 'sprint-2', + workspaceId: SIDE_PROJECT_WORKSPACE_ID, + name: 'Sprint 2', + startDate: '2025-07-01', + endDate: '2025-07-14', + daysLeft: 8, + totalPoints: 42, + completedPoints: 28, +}; + +/** 스프린트별 계획/완료 포인트 추이 */ +export const sprintVelocity: VelocityPoint[] = [ + { sprint: 'S1', planned: 38, completed: 34 }, + { sprint: 'S2', planned: 42, completed: 28 }, +]; diff --git a/src/entities/side-project/sprint/model/sprint.types.ts b/src/entities/side-project/sprint/model/sprint.types.ts new file mode 100644 index 0000000..835a6af --- /dev/null +++ b/src/entities/side-project/sprint/model/sprint.types.ts @@ -0,0 +1,26 @@ +// 스프린트 도메인 모델 — 기간·포인트 메타만 가진다. +// 업무(Task)는 Sprint가 소유하지 않고, Task.sprintId로 이 스프린트를 참조한다. +// 스프린트 업무 조회는 getSprintTasks(sprintId), 백로그는 getBacklogTasks(workspaceId)를 쓴다. +export interface Sprint { + id: string; + /** 소유 워크스페이스 — 스프린트/백로그를 이 값으로 조회한다 */ + workspaceId: string; + name: string; + /** ISO 날짜 (YYYY-MM-DD) */ + startDate: string; + endDate: string; + daysLeft: number; + /** 스프린트 계획 포인트 총합 */ + totalPoints: number; + /** 완료 포인트 */ + completedPoints: number; +} + +export interface VelocityPoint { + sprint: string; + planned: number; + completed: number; +} + +/** 벨로시티 차트 Y축 최댓값 */ +export const VELOCITY_MAX = 60; diff --git a/src/entities/side-project/task/api/get-backlog-tasks.ts b/src/entities/side-project/task/api/get-backlog-tasks.ts new file mode 100644 index 0000000..d540e09 --- /dev/null +++ b/src/entities/side-project/task/api/get-backlog-tasks.ts @@ -0,0 +1,9 @@ +// 백로그(스프린트 미편입) 업무 조회 — Mock 구현. +// 백엔드 준비 시 supabase.from('tasks').select().eq('workspace_id', workspaceId).is('sprint_id', null) 로 교체한다. +// TODO(async): Supabase 전환 시 Promise 반환으로 바꾸고, 소비 위젯을 페칭 구조로 함께 옮긴다. +import type { Task } from '../model/task.types'; +import { mockTasks } from './task.mock'; + +export function getBacklogTasks(workspaceId: string): Task[] { + return mockTasks.filter((task) => task.workspaceId === workspaceId && task.sprintId === null); +} diff --git a/src/entities/side-project/task/api/get-sprint-tasks.ts b/src/entities/side-project/task/api/get-sprint-tasks.ts new file mode 100644 index 0000000..d478b65 --- /dev/null +++ b/src/entities/side-project/task/api/get-sprint-tasks.ts @@ -0,0 +1,9 @@ +// 특정 스프린트에 편입된 업무 조회 — Mock 구현. +// 백엔드 준비 시 supabase.from('tasks').select().eq('sprint_id', sprintId) 로 교체한다. +// TODO(async): Supabase 전환 시 Promise 반환으로 바꾸고, 소비 위젯을 페칭 구조로 함께 옮긴다. +import type { Task } from '../model/task.types'; +import { mockTasks } from './task.mock'; + +export function getSprintTasks(sprintId: string): Task[] { + return mockTasks.filter((task) => task.sprintId === sprintId); +} diff --git a/src/entities/side-project/task/api/task.mock.ts b/src/entities/side-project/task/api/task.mock.ts new file mode 100644 index 0000000..e30421a --- /dev/null +++ b/src/entities/side-project/task/api/task.mock.ts @@ -0,0 +1,149 @@ +// 업무(Task) 목데이터 — api 세그먼트가 소비하는 가짜 서버 데이터. +// 백엔드 준비 시 이 배열 대신 tasks 테이블 조회로 교체된다(get-*-tasks.ts). +// 스프린트 편입 업무는 sprintId = currentSprint.id, 백로그는 sprintId = null. +// Task → Sprint 방향의 의도된 교차 참조(FK 방향과 일치, 비순환): 목 id를 sprint 슬라이스와 동기화한다. +import { currentSprint, SIDE_PROJECT_WORKSPACE_ID } from '@/entities/side-project/sprint'; + +import type { Task } from '../model/task.types'; + +const workspaceId = SIDE_PROJECT_WORKSPACE_ID; +const sprintId = currentSprint.id; + +export const mockTasks: Task[] = [ + // 스프린트 편입 · 대기 (todo) — 13pt + { + id: 'task-1', + workspaceId, + sprintId, + title: '온보딩 플로우 개선', + point: 5, + status: 'todo', + priority: 'medium', + category: 'design', + assignee: { name: '최민준', avatarLabel: '최' }, + }, + { + id: 'task-2', + workspaceId, + sprintId, + title: '성능 최적화 (Lighthouse)', + point: 5, + status: 'todo', + priority: 'medium', + category: 'frontend', + assignee: { name: '박서준', avatarLabel: '박' }, + }, + { + id: 'task-3', + workspaceId, + sprintId, + title: '베타 테스터 모집 공고', + point: 3, + status: 'todo', + priority: 'medium', + category: 'planning', + assignee: { name: '김지은', avatarLabel: '김' }, + }, + // 스프린트 편입 · 진행 중 (in_progress) — 11pt + { + id: 'task-4', + workspaceId, + sprintId, + title: '운동 통계 차트', + point: 8, + status: 'in_progress', + priority: 'medium', + category: 'frontend', + assignee: { name: '박서준', avatarLabel: '박' }, + }, + { + id: 'task-5', + workspaceId, + sprintId, + title: '푸시 알림 설정', + point: 3, + status: 'in_progress', + priority: 'medium', + category: 'planning', + assignee: { name: '김지은', avatarLabel: '김' }, + }, + // 스프린트 편입 · 완료 (done) — 18pt + { + id: 'task-6', + workspaceId, + sprintId, + title: '소셜 로그인 연동', + point: 5, + status: 'done', + priority: 'medium', + category: 'backend', + assignee: { name: '이하은', avatarLabel: '이' }, + }, + { + id: 'task-7', + workspaceId, + sprintId, + title: '운동 기록 CRUD API', + point: 8, + status: 'done', + priority: 'medium', + category: 'backend', + assignee: { name: '이하은', avatarLabel: '이' }, + }, + { + id: 'task-8', + workspaceId, + sprintId, + title: '홈 화면 UI 구현', + point: 5, + status: 'done', + priority: 'medium', + category: 'frontend', + assignee: { name: '박서준', avatarLabel: '박' }, + }, + // 백로그 (sprintId: null) — 카테고리·담당자 미지정, status는 대기(todo) + { + id: 'task-9', + workspaceId, + sprintId: null, + title: '소셜 피드 기능', + point: 13, + status: 'todo', + priority: 'high', + category: null, + assignee: null, + }, + { + id: 'task-10', + workspaceId, + sprintId: null, + title: '운동 친구 매칭', + point: 8, + status: 'todo', + priority: 'medium', + category: null, + assignee: null, + }, + { + id: 'task-11', + workspaceId, + sprintId: null, + title: '영상 가이드 연동', + point: 13, + status: 'todo', + priority: 'low', + category: null, + assignee: null, + }, + { + id: 'task-12', + workspaceId, + sprintId: null, + title: '다크모드 지원', + point: 5, + status: 'todo', + priority: 'low', + category: null, + assignee: null, + }, +]; diff --git a/src/entities/side-project/task/index.ts b/src/entities/side-project/task/index.ts new file mode 100644 index 0000000..0e905b4 --- /dev/null +++ b/src/entities/side-project/task/index.ts @@ -0,0 +1,14 @@ +// task 엔티티의 Public API — 업무(Task) 모델 + 조회 api +// Sprint와 독립된 슬라이스이며, sprintId로 스프린트를 참조한다(Task → Sprint 단방향). +export { + TASK_STATUS, + TASK_PRIORITY, + TASK_CATEGORY, + type Task, + type TaskStatus, + type TaskPriority, + type TaskCategory, + type TaskAssignee, +} from './model/task.types'; +export { getBacklogTasks } from './api/get-backlog-tasks'; +export { getSprintTasks } from './api/get-sprint-tasks'; diff --git a/src/entities/side-project/task/model/task.types.ts b/src/entities/side-project/task/model/task.types.ts new file mode 100644 index 0000000..8d0b92b --- /dev/null +++ b/src/entities/side-project/task/model/task.types.ts @@ -0,0 +1,58 @@ +// 업무(Task) 도메인 모델 — 워크스페이스가 소유하고, 선택적으로 스프린트에 편입된다. +// status는 진행 상태(대기/진행 중/완료)만 나타낸다. +// 백로그 여부는 status가 아니라 sprintId로 판별한다(sprintId === null → 백로그). +export type TaskStatus = 'todo' | 'in_progress' | 'done'; +export type TaskPriority = 'high' | 'medium' | 'low'; +export type TaskCategory = 'design' | 'frontend' | 'backend' | 'planning'; + +interface StatusStyle { + label: string; + dot: string; + bg: string; + text: string; +} + +// 칸반 컬럼/상태 뱃지 스타일 +export const TASK_STATUS: Record = { + todo: { label: '대기', dot: '#d1d5dc', bg: '#f3f4f6', text: '#6a7282' }, + in_progress: { label: '진행 중', dot: '#2b7fff', bg: '#e0e7ff', text: '#432dd7' }, + done: { label: '완료', dot: '#22c55e', bg: '#dcfce7', text: '#16a34a' }, +}; + +// 우선순위 뱃지 색 (Figma 지정값) +export const TASK_PRIORITY: Record = { + high: { label: '높음', color: '#ff6467' }, + medium: { label: '보통', color: '#ffb900' }, + low: { label: '낮음', color: '#d1d5dc' }, +}; + +// 카테고리 태그 스타일 — 색상은 잠정값(스프린트 보드 UI 구축 시 Figma로 확정) +export const TASK_CATEGORY: Record = { + design: { label: 'Design', bg: '#f3e8ff', text: '#7e22ce' }, + frontend: { label: 'Frontend', bg: '#dbeafe', text: '#1d4ed8' }, + backend: { label: 'Backend', bg: '#dcfce7', text: '#15803d' }, + planning: { label: '기획', bg: '#fef3c7', text: '#b45309' }, +}; + +export interface TaskAssignee { + /** 워크스페이스 멤버 표시명 */ + name: string; + /** 아바타 이니셜(성 한 글자) */ + avatarLabel: string; +} + +export interface Task { + id: string; + /** 소유 워크스페이스 — Task의 기준(anchor). 백로그·스프린트 무관하게 항상 존재 */ + workspaceId: string; + /** 편입된 스프린트 id. null이면 백로그(아직 스프린트 미편입) */ + sprintId: string | null; + title: string; + point: number; + status: TaskStatus; + priority: TaskPriority; + /** 칸반 카드 태그. 백로그 항목은 아직 미지정일 수 있어 null 허용 */ + category: TaskCategory | null; + /** 담당자. 미배정(백로그 등) 시 null */ + assignee: TaskAssignee | null; +} diff --git a/src/features/dashboard/edit-layout/index.ts b/src/features/dashboard/edit-layout/index.ts new file mode 100644 index 0000000..6ec4e6e --- /dev/null +++ b/src/features/dashboard/edit-layout/index.ts @@ -0,0 +1,4 @@ +// edit-layout feature의 Public API — 템플릿 무관 대시보드 레이아웃 편집 +export { useDashboardLayout } from './model/useDashboardLayout'; +export { default as DashboardEditToggle } from './ui/DashboardEditToggle'; +export { default as EditModeBanner } from './ui/EditModeBanner'; diff --git a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts new file mode 100644 index 0000000..e7971b4 --- /dev/null +++ b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts @@ -0,0 +1,78 @@ +// 대시보드 레이아웃 편집 상태 훅 — 배치(layout) + 편집 모드를 관리하고 변경을 영속화한다. +// 화면에 배치된 위젯 = layout. 추가는 카탈로그 항목(LayoutItem)을 넣고, 삭제는 layout에서 뺀다. +// 초기 레이아웃은 서버(RSC)에서 조회해 initialLayout으로 주입받는다(마운트 후 재조회 없음). +// 저장(쓰기)만 서버액션으로 위임한다. 영속화 키는 (workspaceId, pageType). editMode는 저장하지 않는다. +import { useCallback, useState } from 'react'; +import type { Layout, LayoutItem } from 'react-grid-layout'; + +import { saveDashboardLayout, type DashboardLayoutState } from '@/entities/dashboard-layout'; + +// 저장·상태로 남기는 값은 위치(i,x,y,w,h)만 — minW/minH 등 위젯 제약은 카탈로그가 소유하며 +// 렌더 시점에 머지한다(DB에 위젯 설정이 중복 저장되지 않도록). +const toPosition = ({ i, x, y, w, h }: LayoutItem): LayoutItem => ({ i, x, y, w, h }); + +interface UseDashboardLayoutParams { + /** 영속화 키 — 어떤 워크스페이스의 어떤 페이지 레이아웃인지 */ + workspaceId: string; + pageType: string; + /** 서버(RSC)에서 조회한 초기 레이아웃 */ + initialLayout: DashboardLayoutState; +} + +export function useDashboardLayout({ + workspaceId, + pageType, + initialLayout, +}: UseDashboardLayoutParams) { + const [layout, setLayout] = useState(initialLayout.layout); + const [editMode, setEditMode] = useState(false); + + // TODO: DB 연동 — 변경 저장 (드래그 중 잦은 호출은 debounce 예정) + const commit = useCallback( + (next: Layout) => { + void saveDashboardLayout(workspaceId, pageType, { layout: next }); + }, + [workspaceId, pageType], + ); + + const handleLayoutChange = useCallback( + (next: Layout) => { + const positions = next.map(toPosition); + setLayout(positions); + commit(positions); + }, + [commit], + ); + + const addWidget = useCallback( + (item: LayoutItem) => + setLayout((prev) => { + if (prev.some((entry) => entry.i === item.i)) return prev; + const next = [...prev, toPosition(item)]; + commit(next); + return next; + }), + [commit], + ); + + const removeWidget = useCallback( + (id: string) => + setLayout((prev) => { + const next = prev.filter((entry) => entry.i !== id); + commit(next); + return next; + }), + [commit], + ); + + const toggleEdit = useCallback(() => setEditMode((prev) => !prev), []); + + return { + layout, + editMode, + handleLayoutChange, + addWidget, + removeWidget, + toggleEdit, + }; +} diff --git a/src/features/dashboard/edit-layout/ui/DashboardEditToggle.tsx b/src/features/dashboard/edit-layout/ui/DashboardEditToggle.tsx new file mode 100644 index 0000000..65b3c3f --- /dev/null +++ b/src/features/dashboard/edit-layout/ui/DashboardEditToggle.tsx @@ -0,0 +1,30 @@ +// 레이아웃 편집 토글 — 우하단 플로팅 버튼. 상태는 갖지 않고 props로만 제어되는 순수 컴포넌트. +// · 보기 모드: 흰 버튼 "레이아웃 편집" +// · 편집 모드: 인디고 채움 버튼 "편집 완료" +import { Check, Pencil } from 'lucide-react'; + +import { cn } from '@/shared/lib/utils'; + +interface DashboardEditToggleProps { + editing: boolean; + onToggle: () => void; +} + +export default function DashboardEditToggle({ editing, onToggle }: DashboardEditToggleProps) { + return ( + + ); +} diff --git a/src/features/dashboard/edit-layout/ui/EditModeBanner.tsx b/src/features/dashboard/edit-layout/ui/EditModeBanner.tsx new file mode 100644 index 0000000..e09a694 --- /dev/null +++ b/src/features/dashboard/edit-layout/ui/EditModeBanner.tsx @@ -0,0 +1,14 @@ +// 편집 모드 안내 배너 — 편집 모드일 때 대시보드 상단에 노출 +import { GripVertical } from 'lucide-react'; + +export default function EditModeBanner() { + return ( +
+ +

+ 편집 모드 — 좌상단 ⠿ 이동 · 우하단 ↘ 크기 조절 · + 우상단 🗑 삭제 · 아래 위젯 추가 버튼으로 카드 추가 +

+
+ ); +} diff --git a/src/features/manage-notices/ui/NoticeDetailPanel.tsx b/src/features/manage-notices/ui/NoticeDetailPanel.tsx index 20bd0d2..3c97dd9 100644 --- a/src/features/manage-notices/ui/NoticeDetailPanel.tsx +++ b/src/features/manage-notices/ui/NoticeDetailPanel.tsx @@ -20,12 +20,9 @@ export function NoticeDetailPanel({ notice }: NoticeDetailPanelProps) {
{notice.isPinned ? ( -

{notice.authorName} · {notice.createdAt} @@ -39,7 +36,9 @@ export function NoticeDetailPanel({ notice }: NoticeDetailPanelProps) { ) : null}

-

{notice.content}

+

+ {notice.content} +

); } diff --git a/src/features/manage-resources/model/use-resource-library-state.ts b/src/features/manage-resources/model/use-resource-library-state.ts index 8afef6f..03d95c2 100644 --- a/src/features/manage-resources/model/use-resource-library-state.ts +++ b/src/features/manage-resources/model/use-resource-library-state.ts @@ -32,7 +32,8 @@ export function useResourceLibraryState({ uploaderName, }: UseResourceLibraryStateParams) { const workspaceResources = useMemo( - () => sortResources(initialResources.filter((resource) => resource.workspaceId === workspaceId)), + () => + sortResources(initialResources.filter((resource) => resource.workspaceId === workspaceId)), [initialResources, workspaceId], ); const [resourcesByWorkspaceId, setResourcesByWorkspaceId] = useState< diff --git a/src/features/project-board/ui/ProjectBoard.tsx b/src/features/project-board/ui/ProjectBoard.tsx index d8c2ee1..2169b1a 100644 --- a/src/features/project-board/ui/ProjectBoard.tsx +++ b/src/features/project-board/ui/ProjectBoard.tsx @@ -166,26 +166,25 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { return (
-

+

프로젝트 관리

{isComposerOpen ? ( -
-
+
+
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 ( + + + {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 ( +
+ {/* 스프린트 배너 (그라데이션) */} +
+
+

{name}

+

{period}

+
+

+ {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 ( +