-
Notifications
You must be signed in to change notification settings - Fork 3
[Feat] 진행률 차트 페이지 구현 #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { ProgressChartPage } from '@/views/progress-chart'; | ||
|
|
||
| interface WorkspaceProgressChartPageProps { | ||
| params: Promise<{ | ||
| workspaceId: string; | ||
| }>; | ||
| } | ||
|
|
||
| export default async function WorkspaceProgressChartPage({ | ||
| params, | ||
| }: WorkspaceProgressChartPageProps) { | ||
| const { workspaceId } = await params; | ||
|
|
||
| return <ProgressChartPage workspaceId={workspaceId} />; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| export type { | ||
| ProgressChartSummary, | ||
| ProgressChartAssigneeItem, | ||
| ProgressChartStatusItem, | ||
| } from './model/progress-chart.types'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| export interface ProgressChartStatusItem { | ||
| id: 'done' | 'in-progress' | 'todo'; | ||
| label: string; | ||
| count: number; | ||
| color: string; | ||
| } | ||
|
|
||
| export interface ProgressChartAssigneeItem { | ||
| name: string; | ||
| count: number; | ||
| } | ||
|
|
||
| export interface ProgressChartSummary { | ||
| totalTaskCount: number; | ||
| doneTaskCount: number; | ||
| inProgressTaskCount: number; | ||
| overallProgressRate: number; | ||
| assigneeItems: ProgressChartAssigneeItem[]; | ||
| statusItems: ProgressChartStatusItem[]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { ProgressChartView } from './ui/ProgressChartView'; |
80 changes: 80 additions & 0 deletions
80
src/features/manage-progress-chart/model/progress-chart.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import type { Task, TaskStatus } from '@/entities/task'; | ||
| import type { | ||
| ProgressChartAssigneeItem, | ||
| ProgressChartStatusItem, | ||
| ProgressChartSummary, | ||
| } from '@/entities/progress-chart'; | ||
|
|
||
| // 진행률 차트는 별도 목업 숫자를 두지 않고 프로젝트 관리 task 목록에서 직접 파생한다. | ||
| const STATUS_META: Record<TaskStatus, Pick<ProgressChartStatusItem, 'label' | 'color'>> = { | ||
| 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<string, ProgressChartAssigneeItem>(); | ||
| 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, | ||
| }; | ||
| } |
223 changes: 223 additions & 0 deletions
223
src/features/manage-progress-chart/ui/ProgressChartView.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| 'use client'; | ||
|
|
||
| import type { ProgressChartAssigneeItem, ProgressChartStatusItem } from '@/entities/progress-chart'; | ||
| import { getMockTasksByWorkspaceId } from '@/entities/task'; | ||
| import { cn } from '@/shared/lib/utils'; | ||
| import { createProgressChartSummary } from '../model/progress-chart'; | ||
|
|
||
| interface ProgressChartViewProps { | ||
| workspaceId: string; | ||
| } | ||
|
|
||
| function SummaryNumberCard({ | ||
| value, | ||
| label, | ||
| valueClassName, | ||
| }: { | ||
| value: number; | ||
| label: string; | ||
| valueClassName?: string; | ||
| }) { | ||
| return ( | ||
| <div className="flex min-h-[214px] items-center justify-center rounded-[22px] border border-[#e7eaff] bg-white px-7 py-8 shadow-[0_6px_20px_rgba(91,78,232,0.03)]"> | ||
| <div className="text-center"> | ||
| <strong | ||
| className={cn( | ||
| 'text-brand-ink text-[46px] leading-none font-extrabold tracking-[-0.06em]', | ||
| valueClassName, | ||
| )} | ||
| > | ||
| {value} | ||
| </strong> | ||
| <p className="mt-2.5 text-[15px] font-semibold tracking-[-0.04em] text-[#9ca2c5]"> | ||
| {label} | ||
| </p> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function OverallProgressCard({ | ||
| progress, | ||
| doneCount, | ||
| totalCount, | ||
| }: { | ||
| progress: number; | ||
| doneCount: number; | ||
| totalCount: number; | ||
| }) { | ||
| const safeProgress = Math.max(progress, 0); | ||
|
|
||
| return ( | ||
| <div className="rounded-[22px] border border-[#e7eaff] bg-white px-6 pt-9 pb-10 shadow-[0_6px_20px_rgba(91,78,232,0.03)]"> | ||
| <h2 className="text-brand-ink text-[17px] font-bold tracking-[-0.04em]">전체 진행률</h2> | ||
| <p className="mt-2 text-[13px] font-medium tracking-[-0.03em] text-[#98a0c6]"> | ||
| {doneCount} / {totalCount} 업무 완료 | ||
| </p> | ||
|
|
||
| <div className="mt-5 h-6 overflow-hidden rounded-full bg-[#eceffc]"> | ||
| <div | ||
| className="flex h-full items-center justify-end rounded-full bg-[linear-gradient(90deg,#534bf2_0%,#8a56ff_100%)] pr-4" | ||
| style={{ width: `${safeProgress}%` }} | ||
| > | ||
| <span className="text-[12px] font-bold tracking-[-0.02em] text-white"> | ||
| {safeProgress}% | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function AssigneeBarChartCard({ items }: { items: ProgressChartAssigneeItem[] }) { | ||
| const maxValue = Math.max(...items.map((item) => item.count), 0); | ||
| const gridValues = [0, 2, 4, 6, 8]; | ||
|
|
||
| return ( | ||
| <div className="row-span-2 rounded-[22px] border border-[#e7eaff] bg-white px-6 pt-6 pb-5 shadow-[0_6px_20px_rgba(91,78,232,0.03)]"> | ||
| <h2 className="text-brand-ink text-[17px] font-bold tracking-[-0.04em]"> | ||
| 담당자별 업무 현황 | ||
| </h2> | ||
|
|
||
| <div className="mt-6 grid grid-cols-[28px_1fr] gap-3"> | ||
| <div className="flex h-[326px] flex-col justify-between pb-8 text-right text-[11px] font-medium text-[#9aa2c7]"> | ||
| {gridValues | ||
| .slice() | ||
| .reverse() | ||
| .map((value) => ( | ||
| <span key={value}>{value}</span> | ||
| ))} | ||
| </div> | ||
|
|
||
| <div className="relative h-[326px]"> | ||
| <div className="absolute inset-0 flex flex-col justify-between pb-8"> | ||
| {gridValues | ||
| .slice(1) | ||
| .reverse() | ||
| .map((value) => ( | ||
| <div key={value} className="border-t border-dashed border-[#eceef8]" /> | ||
| ))} | ||
| <div className="border-t border-dashed border-[#eceef8]" /> | ||
| </div> | ||
|
|
||
| <div className="relative flex h-full items-end justify-around gap-6 pb-8"> | ||
| {items.map((item) => ( | ||
| <div key={item.name} className="flex h-full flex-1 flex-col items-center justify-end"> | ||
| <div | ||
| className="w-full max-w-[18px] rounded-t-[7px] bg-[#ddd9ff]" | ||
| style={{ | ||
| height: `${maxValue === 0 ? 0 : (item.count / maxValue) * 214}px`, | ||
| }} | ||
| /> | ||
| <span className="mt-3 text-[13px] font-medium tracking-[-0.03em] text-[#8f97bf]"> | ||
| {item.name} | ||
| </span> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
JiWoongE marked this conversation as resolved.
|
||
|
|
||
| function StatusDistributionCard({ items }: { items: ProgressChartStatusItem[] }) { | ||
| const total = items.reduce((sum, item) => sum + item.count, 0); | ||
| const segments = items | ||
| .map((item, index) => { | ||
| const start = items | ||
| .slice(0, index) | ||
| .reduce((sum, current) => sum + (total === 0 ? 0 : (current.count / total) * 100), 0); | ||
| const end = start + (total === 0 ? 0 : (item.count / total) * 100); | ||
|
|
||
| return `${item.color} ${start}% ${end}%`; | ||
| }) | ||
| .join(', '); | ||
|
|
||
| return ( | ||
| <div className="rounded-[22px] border border-[#e7eaff] bg-white px-6 pt-6 pb-7 shadow-[0_6px_20px_rgba(91,78,232,0.03)]"> | ||
| <h2 className="text-brand-ink text-[17px] font-bold tracking-[-0.04em]">상태 분포</h2> | ||
|
|
||
| <div className="flex flex-col items-center justify-center gap-8 pt-9 pb-1 lg:flex-row"> | ||
| <div | ||
| className="relative size-[152px] rounded-full" | ||
| style={{ | ||
| background: `conic-gradient(${segments})`, | ||
| }} | ||
| > | ||
| <div className="absolute inset-[28px] rounded-full bg-white" /> | ||
| </div> | ||
|
|
||
| <div className="min-w-[146px] space-y-4.5"> | ||
| {items.map((item) => ( | ||
| <div key={item.id} className="flex items-center gap-4"> | ||
| <span | ||
| className="size-[14px] rounded-full" | ||
| style={{ backgroundColor: item.color }} | ||
| aria-hidden="true" | ||
| /> | ||
| <div className="flex min-w-[116px] items-center justify-between gap-5"> | ||
| <span className="text-brand-ink text-[15px] font-semibold tracking-[-0.03em]"> | ||
| {item.label} | ||
| </span> | ||
| <strong className="text-brand-ink text-[15px] font-extrabold tracking-[-0.04em]"> | ||
| {item.count}건 | ||
| </strong> | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export function ProgressChartView({ workspaceId }: ProgressChartViewProps) { | ||
| const tasks = getMockTasksByWorkspaceId(workspaceId); | ||
| const summary = createProgressChartSummary(tasks); | ||
|
|
||
| return ( | ||
| <section className="w-full max-w-[1280px]"> | ||
| <header className="mb-6"> | ||
| <h1 className="text-brand-ink text-[28px] leading-[1.15] font-extrabold tracking-[-0.05em]"> | ||
| 진행률 차트 | ||
| </h1> | ||
| </header> | ||
|
|
||
| <div className="grid grid-cols-1 gap-4 xl:grid-cols-12"> | ||
| <div className="xl:col-span-4"> | ||
| <SummaryNumberCard value={summary.totalTaskCount} label="전체 업무" /> | ||
| </div> | ||
| <div className="xl:col-span-4"> | ||
| <SummaryNumberCard | ||
| value={summary.doneTaskCount} | ||
| label="완료" | ||
| valueClassName="text-[#00b73d]" | ||
| /> | ||
| </div> | ||
| <div className="xl:col-span-4"> | ||
| <SummaryNumberCard | ||
| value={summary.inProgressTaskCount} | ||
| label="진행 중" | ||
| valueClassName="text-[#615bff]" | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="xl:col-span-6"> | ||
| <OverallProgressCard | ||
| progress={summary.overallProgressRate} | ||
| doneCount={summary.doneTaskCount} | ||
| totalCount={summary.totalTaskCount} | ||
| /> | ||
| </div> | ||
| <div className="xl:col-span-6 xl:row-span-2"> | ||
| <AssigneeBarChartCard items={summary.assigneeItems} /> | ||
| </div> | ||
|
|
||
| <div className="xl:col-span-6"> | ||
| <StatusDistributionCard items={summary.statusItems} /> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default as ProgressChartPage } from './ui/ProgressChartPage'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { ProgressChartView } from '@/features/manage-progress-chart'; | ||
| import { plusJakartaSans } from '@/shared/lib/fonts'; | ||
|
|
||
| interface ProgressChartPageProps { | ||
| workspaceId: string; | ||
| } | ||
|
|
||
| export default function ProgressChartPage({ workspaceId }: ProgressChartPageProps) { | ||
| return ( | ||
| <div className={`${plusJakartaSans.className} bg-brand-surface min-h-full`}> | ||
| <ProgressChartView workspaceId={workspaceId} /> | ||
| </div> | ||
| ); | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.