# PlanitAI KPI - 프론트엔드 설계 **시리즈**: PlanitAI KPI 개발기 v8/16 **작성일**: 2025-12-07 **작성자**: GemEgg Dev Team --- ## 1. 프론트엔드 아키텍처 ### 1.1 기술 스택 ``` ┌─────────────────────────────────────────────────────────────┐ │ Frontend Technology Stack │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Framework: Next.js 14 (App Router) │ │ Language: TypeScript 5.3+ │ │ Styling: Tailwind CSS + shadcn/ui │ │ State: Zustand + React Query │ │ Charts: Recharts + D3.js │ │ Forms: React Hook Form + Zod │ │ Testing: Vitest + Playwright │ │ │ └─────────────────────────────────────────────────────────────┘ ``` ### 1.2 디렉토리 구조 ``` src/ ├── app/ # Next.js App Router │ ├── (auth)/ # 인증 관련 페이지 │ │ ├── login/ │ │ └── signup/ │ ├── (dashboard)/ # 대시보드 레이아웃 │ │ ├── layout.tsx │ │ ├── page.tsx # 메인 대시보드 │ │ ├── kpi-trees/ │ │ │ ├── page.tsx # 트리 목록 │ │ │ └── [id]/ │ │ │ ├── page.tsx # 트리 상세 │ │ │ ├── edit/ │ │ │ └── analysis/ │ │ ├── reports/ │ │ └── settings/ │ └── api/ # API Routes (프록시) │ ├── components/ │ ├── ui/ # shadcn/ui 컴포넌트 │ ├── kpi/ # KPI 관련 컴포넌트 │ │ ├── KPICard.tsx │ │ ├── KPITree.tsx │ │ ├── KPITable.tsx │ │ └── KPIChart.tsx │ ├── analysis/ # 분석 컴포넌트 │ ├── layout/ # 레이아웃 컴포넌트 │ └── common/ # 공통 컴포넌트 │ ├── lib/ │ ├── api/ # API 클라이언트 │ ├── hooks/ # 커스텀 훅 │ ├── stores/ # Zustand 스토어 │ └── utils/ # 유틸리티 │ ├── types/ # TypeScript 타입 └── styles/ # 글로벌 스타일 ``` --- ## 2. 페이지 구조 ### 2.1 페이지 맵 ``` ┌─────────────────────────────────────────────────────────────┐ │ Page Structure │ ├─────────────────────────────────────────────────────────────┤ │ │ │ /login 로그인 │ │ /signup 회원가입 │ │ │ │ / 대시보드 (요약) │ │ ├── KPI 카드 그리드 │ │ ├── 트렌드 차트 │ │ └── AI 인사이트 │ │ │ │ /kpi-trees KPI 트리 목록 │ │ /kpi-trees/new 트리 생성 │ │ /kpi-trees/:id 트리 상세 │ │ ├── Overview 개요 + KPI 카드 │ │ ├── Tree 트리 시각화 │ │ ├── Table 테이블 뷰 │ │ └── Analysis 분석 (병목, 시뮬레이션) │ │ │ │ /kpi-trees/:id/edit 트리 편집 │ │ /kpi-trees/:id/data 데이터 입력 │ │ │ │ /reports 리포트 목록 │ │ /reports/generate 리포트 생성 │ │ /reports/:id 리포트 상세 │ │ │ │ /settings 설정 │ │ ├── Organization 조직 설정 │ │ ├── Users 사용자 관리 │ │ ├── Integrations 연동 설정 │ │ └── Billing 결제 (유료) │ │ │ └─────────────────────────────────────────────────────────────┘ ``` --- ## 3. 핵심 컴포넌트 설계 ### 3.1 KPI 카드 ```tsx // components/kpi/KPICard.tsx import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { TrendingUp, TrendingDown, Minus } from "lucide-react"; interface KPICardProps { title: string; value: number; unit: string; target?: number; variance?: number; varianceRate?: number; achievementRate?: number; category: string; trend?: "up" | "down" | "flat"; } export function KPICard({ title, value, unit, target, variance, varianceRate, achievementRate, category, trend, }: KPICardProps) { const categoryColors = { finance: "border-l-blue-500", sales: "border-l-green-500", marketing: "border-l-purple-500", hr: "border-l-orange-500", product: "border-l-cyan-500", }; const isPositive = (varianceRate ?? 0) >= 0; return (
{title} {trend && ( {trend === "up" && } {trend === "down" && } {trend === "flat" && } )}
{formatValue(value, unit)}
{target && (
目標: {formatValue(target, unit)}
)} {variance !== undefined && (
{isPositive ? "+" : ""}{formatValue(variance, unit)} {varianceRate !== undefined && ( ({isPositive ? "+" : ""}{varianceRate.toFixed(1)}%) )}
)} {achievementRate !== undefined && (
達成率 {achievementRate.toFixed(1)}%
= 100 ? "bg-green-500" : achievementRate >= 80 ? "bg-yellow-500" : "bg-red-500" )} style={{ width: `${Math.min(achievementRate, 100)}%` }} />
)} ); } function formatValue(value: number, unit: string): string { if (unit === "円" || unit === "currency") { return `¥${value.toLocaleString()}`; } if (unit === "%" || unit === "percentage") { return `${value.toFixed(1)}%`; } return `${value.toLocaleString()}${unit}`; } ``` ### 3.2 KPI 트리 시각화 ```tsx // components/kpi/KPITree.tsx "use client"; import { useEffect, useRef } from "react"; import * as d3 from "d3"; import { KPITreeData, KPINode } from "@/types/kpi"; interface KPITreeProps { data: KPITreeData; width?: number; height?: number; onNodeClick?: (node: KPINode) => void; } export function KPITree({ data, width = 900, height = 600, onNodeClick, }: KPITreeProps) { const svgRef = useRef(null); useEffect(() => { if (!svgRef.current || !data) return; const svg = d3.select(svgRef.current); svg.selectAll("*").remove(); const margin = { top: 20, right: 120, bottom: 20, left: 120 }; const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; const g = svg .append("g") .attr("transform", `translate(${margin.left},${margin.top})`); // 트리 레이아웃 const treeLayout = d3.tree().size([innerHeight, innerWidth]); // 계층 구조 생성 const root = d3.hierarchy(data.root, (d) => d.children); const treeData = treeLayout(root); // 링크 그리기 g.selectAll(".link") .data(treeData.links()) .enter() .append("path") .attr("class", "link") .attr("fill", "none") .attr("stroke", "#94a3b8") .attr("stroke-width", 2) .attr("d", d3.linkHorizontal() .x((d: any) => d.y) .y((d: any) => d.x) ); // 노드 그룹 const nodes = g.selectAll(".node") .data(treeData.descendants()) .enter() .append("g") .attr("class", "node") .attr("transform", (d) => `translate(${d.y},${d.x})`) .style("cursor", "pointer") .on("click", (event, d) => { onNodeClick?.(d.data); }); // 노드 배경 nodes.append("rect") .attr("x", -60) .attr("y", -25) .attr("width", 120) .attr("height", 50) .attr("rx", 8) .attr("fill", (d) => getNodeColor(d.data)) .attr("stroke", (d) => getNodeBorderColor(d.data)) .attr("stroke-width", 2); // 노드 이름 nodes.append("text") .attr("dy", -5) .attr("text-anchor", "middle") .attr("font-size", "12px") .attr("font-weight", "600") .attr("fill", "#1e293b") .text((d) => truncate(d.data.name, 10)); // 노드 값 nodes.append("text") .attr("dy", 15) .attr("text-anchor", "middle") .attr("font-size", "14px") .attr("font-weight", "700") .attr("fill", (d) => getValueColor(d.data)) .text((d) => formatNodeValue(d.data)); // 달성률 표시 nodes.append("text") .attr("dy", 30) .attr("text-anchor", "middle") .attr("font-size", "10px") .attr("fill", "#64748b") .text((d) => d.data.achievementRate ? `${d.data.achievementRate.toFixed(0)}%` : ""); }, [data, width, height, onNodeClick]); return (
); } // 헬퍼 함수들 function getNodeColor(node: KPINode): string { const colors = { kgi: "#dbeafe", kpi: "#f0fdf4", metric: "#fef3c7", input: "#f1f5f9", }; return colors[node.nodeType] || colors.input; } function getNodeBorderColor(node: KPINode): string { if (!node.achievementRate) return "#94a3b8"; if (node.achievementRate >= 100) return "#16a34a"; if (node.achievementRate >= 80) return "#ca8a04"; return "#dc2626"; } function getValueColor(node: KPINode): string { if (!node.achievementRate) return "#1e293b"; if (node.achievementRate >= 100) return "#16a34a"; if (node.achievementRate >= 80) return "#ca8a04"; return "#dc2626"; } function formatNodeValue(node: KPINode): string { const value = node.actual ?? node.plan ?? 0; if (node.unit === "currency") { if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; if (value >= 1000) return `${(value / 1000).toFixed(0)}K`; } if (node.unit === "percentage") return `${value.toFixed(1)}%`; return value.toLocaleString(); } function truncate(str: string, length: number): string { return str.length > length ? str.slice(0, length) + "..." : str; } ``` ### 3.3 KPI 테이블 ```tsx // components/kpi/KPITable.tsx "use client"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; interface KPITableProps { data: KPITableRow[]; periods: string[]; showVariance?: boolean; } interface KPITableRow { id: string; name: string; category: string; level: number; isTotal: boolean; values: Record; totalPlan?: number; totalActual?: number; totalVariance?: number; } export function KPITable({ data, periods, showVariance = true, }: KPITableProps) { return (
科目 {periods.map((period) => ( {period} ))} 合計 {showVariance && ( <> 差異 達成率 )} {data.map((row) => ( 0 && "text-sm text-muted-foreground" )} >
{row.name} {row.category}
{periods.map((period) => { const value = row.values[period]; return ( {value?.actual !== undefined ? formatCurrency(value.actual) : "-"} ); })} {row.totalActual !== undefined ? formatCurrency(row.totalActual) : "-"} {showVariance && ( <> = 0 ? "text-green-600" : "text-red-600" )}> {row.totalVariance !== undefined ? formatCurrency(row.totalVariance, true) : "-"} {row.values[periods[periods.length - 1]]?.achievementRate !== undefined ? `${row.values[periods[periods.length - 1]].achievementRate?.toFixed(1)}%` : "-"} )}
))}
); } function formatCurrency(value: number, showSign = false): string { const formatted = Math.abs(value).toLocaleString(); if (showSign) { return value >= 0 ? `+${formatted}` : `▲${formatted}`; } return value < 0 ? `▲${formatted}` : formatted; } ``` --- ## 4. 상태 관리 ### 4.1 Zustand 스토어 ```typescript // lib/stores/kpi-store.ts import { create } from "zustand"; import { KPITree, KPINode } from "@/types/kpi"; interface KPIState { // 현재 선택된 트리 currentTree: KPITree | null; setCurrentTree: (tree: KPITree | null) => void; // 선택된 노드 selectedNodeId: string | null; setSelectedNodeId: (id: string | null) => void; // 기간 선택 selectedPeriod: string; setSelectedPeriod: (period: string) => void; // 필터 categoryFilter: string[]; setCategoryFilter: (categories: string[]) => void; // 뷰 모드 viewMode: "card" | "tree" | "table"; setViewMode: (mode: "card" | "tree" | "table") => void; // 로딩 상태 isLoading: boolean; setIsLoading: (loading: boolean) => void; } export const useKPIStore = create((set) => ({ currentTree: null, setCurrentTree: (tree) => set({ currentTree: tree }), selectedNodeId: null, setSelectedNodeId: (id) => set({ selectedNodeId: id }), selectedPeriod: getCurrentPeriod(), setSelectedPeriod: (period) => set({ selectedPeriod: period }), categoryFilter: [], setCategoryFilter: (categories) => set({ categoryFilter: categories }), viewMode: "card", setViewMode: (mode) => set({ viewMode: mode }), isLoading: false, setIsLoading: (loading) => set({ isLoading: loading }), })); function getCurrentPeriod(): string { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; } ``` ### 4.2 React Query 훅 ```typescript // lib/hooks/use-kpi-tree.ts import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { kpiApi } from "@/lib/api/kpi"; export function useKPITree(treeId: string) { return useQuery({ queryKey: ["kpi-tree", treeId], queryFn: () => kpiApi.getTree(treeId), staleTime: 1000 * 60 * 5, // 5분 }); } export function useKPITreeList(params?: { fiscalYear?: string }) { return useQuery({ queryKey: ["kpi-trees", params], queryFn: () => kpiApi.listTrees(params), }); } export function useUpdateKPIData() { const queryClient = useQueryClient(); return useMutation({ mutationFn: kpiApi.updateData, onSuccess: (_, variables) => { // 관련 쿼리 무효화 queryClient.invalidateQueries({ queryKey: ["kpi-tree", variables.treeId], }); queryClient.invalidateQueries({ queryKey: ["kpi-analysis"], }); }, }); } export function useKPIAnalysis(treeId: string, period: string) { return useQuery({ queryKey: ["kpi-analysis", treeId, period], queryFn: () => kpiApi.getAnalysis(treeId, period), staleTime: 1000 * 60 * 10, // 10분 }); } export function useKPISimulation() { return useMutation({ mutationFn: kpiApi.simulate, }); } ``` --- ## 5. 페이지 구현 예시 ### 5.1 대시보드 페이지 ```tsx // app/(dashboard)/page.tsx import { Suspense } from "react"; import { KPICardGrid } from "@/components/kpi/KPICardGrid"; import { TrendChart } from "@/components/analysis/TrendChart"; import { AIInsight } from "@/components/analysis/AIInsight"; import { BottleneckAlert } from "@/components/analysis/BottleneckAlert"; import { Skeleton } from "@/components/ui/skeleton"; export default function DashboardPage() { return (

ダッシュボード

{/* KPI 요약 카드 */}

主要KPI

}>
{/* 트렌드 차트 */}

売上推移

}>

KPI達成率

}>
{/* 병목 알림 */}

注意が必要なKPI

}>
{/* AI 인사이트 */}

AIインサイト

}>
); } function KPICardGridSkeleton() { return (
{[...Array(4)].map((_, i) => ( ))}
); } ``` --- ## 6. 디자인 시스템 ### 6.1 색상 팔레트 ```css /* KPI 카테고리 색상 */ :root { --kpi-finance: #2563eb; /* 파랑 - 재무 */ --kpi-sales: #16a34a; /* 초록 - 영업 */ --kpi-marketing: #8b5cf6; /* 보라 - 마케팅 */ --kpi-hr: #f59e0b; /* 주황 - 인력 */ --kpi-product: #0891b2; /* 청록 - 제품 */ --kpi-customer: #ec4899; /* 핑크 - 고객 */ /* 상태 색상 */ --status-positive: #16a34a; --status-negative: #dc2626; --status-warning: #ca8a04; --status-neutral: #64748b; } ``` ### 6.2 컴포넌트 스타일 가이드 ``` ┌─────────────────────────────────────────────────────────────┐ │ Component Style Guide │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 카드 (Card) │ │ ├── 테두리: rounded-lg (8px) │ │ ├── 그림자: shadow-sm → shadow-lg (hover) │ │ ├── 패딩: p-4 ~ p-6 │ │ └── 카테고리: border-l-4 색상 │ │ │ │ 버튼 (Button) │ │ ├── Primary: bg-blue-600, text-white │ │ ├── Secondary: bg-gray-100, text-gray-900 │ │ ├── Ghost: bg-transparent, text-gray-600 │ │ └── 크기: h-8, h-10, h-12 │ │ │ │ 테이블 (Table) │ │ ├── 헤더: bg-gray-50, font-medium │ │ ├── 행: hover:bg-gray-50 │ │ ├── 합계행: bg-blue-50, font-semibold │ │ └── 음수: text-red-600, ▲ prefix │ │ │ │ 차트 (Chart) │ │ ├── 실적: #2563eb (파랑) │ │ ├── 예산: #94a3b8 (회색) │ │ ├── 목표선: #dc2626 (빨강, 점선) │ │ └── 축: #64748b, 12px │ │ │ └─────────────────────────────────────────────────────────────┘ ``` --- ## 7. 결론 ### 프론트엔드 설계 요약 | 영역 | 기술 | 역할 | |------|------|------| | Framework | Next.js 14 | SSR, 라우팅 | | UI | shadcn/ui | 컴포넌트 라이브러리 | | 상태 | Zustand + React Query | 클라이언트/서버 상태 | | 차트 | Recharts + D3.js | 시각화 | | 스타일 | Tailwind CSS | 유틸리티 CSS | ### 핵심 컴포넌트 1. **KPICard**: 개별 KPI 카드 (달성률, 추이 표시) 2. **KPITree**: D3.js 기반 트리 시각화 3. **KPITable**: 기간별 데이터 테이블 --- **다음 편: [v9] AI 분석 엔진 설계** *Gemini AI 통합 설계, 프롬프트 엔지니어링, 분석 유형별 생성 로직을 다룹니다.*