Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/app/workspaces/[workspaceId]/progress-chart/page.tsx
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} />;
}
5 changes: 5 additions & 0 deletions src/entities/progress-chart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type {
ProgressChartSummary,
ProgressChartAssigneeItem,
ProgressChartStatusItem,
} from './model/progress-chart.types';
20 changes: 20 additions & 0 deletions src/entities/progress-chart/model/progress-chart.types.ts
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[];
}
1 change: 1 addition & 0 deletions src/features/manage-progress-chart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ProgressChartView } from './ui/ProgressChartView';
80 changes: 80 additions & 0 deletions src/features/manage-progress-chart/model/progress-chart.ts
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 src/features/manage-progress-chart/ui/ProgressChartView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
'use client';
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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>
);
}
Comment thread
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>
);
}
1 change: 1 addition & 0 deletions src/views/progress-chart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ProgressChartPage } from './ui/ProgressChartPage';
14 changes: 14 additions & 0 deletions src/views/progress-chart/ui/ProgressChartPage.tsx
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>
);
}