import { html, css, nothing } from 'lit' import { customElement } from 'lit/decorators.js' import { PageView } from '@operato/shell' import { ScrollbarStyles } from '@operato/styles' import { client } from '@operato/graphql' import gql from 'graphql-tag' import { state } from 'lit/decorators.js' import '@material/web/icon/icon.js' import './kpi-performance-summary' import './kpi-grade-visualization' import './kpi-history-viewer' import './kpi-list-summary' import './kpi-value-entry' import './kpi-alert-panel' import '../../charts/kpi-radar-chart' import '../../charts/kpi-boxplot-chart' // 3레벨 KPI 플로팅 컴포넌트들 import './cards/kpi-level1-card' import './cards/kpi-level2-comparison' import './cards/kpi-level3-comparison' @customElement('kpi-dashboard') export class KpiDashboardPage extends PageView { static styles = [ ScrollbarStyles, css` :host { display: flex; flex-direction: column; overflow-y: auto; } .dashboard-root { flex: 1; padding: 24px; } .sample-charts-section { display: flex; flex-direction: column; gap: 40px; margin-bottom: 48px; align-items: flex-start; } .sample-chart-card { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); border: 1px solid #ececec; padding: 24px 32px; min-width: 340px; flex: 1; display: flex; flex-direction: column; align-items: stretch; min-height: 500px; } .sample-chart-title { font-size: 1.1rem; font-weight: bold; margin-bottom: 12px; } .sample-chart-container { width: 100%; height: 340px; min-height: 340px; } .category-section { margin-bottom: 40px; } .category-title { font-size: 1.3rem; font-weight: bold; margin-bottom: 16px; } .kpi-cards { display: flex; gap: 24px; flex-wrap: wrap; } .kpi-card { background: #fff; border-radius: 12px; padding: 24px 32px; min-width: 220px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); display: flex; flex-direction: column; align-items: flex-start; margin-bottom: 8px; border: 1px solid #ececec; } .kpi-name { font-size: 1.1rem; font-weight: 500; margin-bottom: 8px; } .kpi-value { font-size: 2.2rem; font-weight: bold; color: #3a3ad6; margin-bottom: 4px; } .kpi-target { font-size: 1rem; color: #888; } ` ] @state() categories: any[] = [] @state() loading = true @state() error = '' @state() alerts: any[] = [] @state() showHistoryModal: boolean = false @state() modalHistories: any[] = [] @state() modalKpiName: string = '' @state() kpiStatistics: any[] = [] // 실제 KPI 통계 데이터 @state() statisticsLoading = true // 통계 데이터 로딩 상태 @state() selectedPeriodType: string = 'MONTH' // 선택된 기간 타입 (MONTH로 고정) @state() selectedValueDate: string = '' // 선택된 값 날짜 // 샘플 데이터 private get sampleCategories(): string[] { return ['생산성', '품질', '안전', '환경', '비용', '일정'] } private get sampleGroups(): string[] { return ['A', 'B', 'C'] } private get sampleSeriesData(): any[] { // 각 카테고리별로 10개 이상의 다양한 값(평균, 분산, 이상치 포함) // 생산성: 평균 높고 분산 큼, 이상치 포함 const 생산성 = [95, 92, 90, 88, 85, 80, 78, 75, 70, 60, 100] // 100은 이상치 // 품질: 평균 높고 분산 작음 const 품질 = [90, 89, 88, 87, 86, 85, 84, 83, 82, 80, 70] // 안전: 평균 중간, 이상치 포함 const 안전 = [92, 91, 90, 89, 88, 87, 86, 85, 84, 65, 60] // 60은 이상치 // 환경: 낮은 값에 몰림, 분산 큼 const 환경 = [95, 90, 85, 80, 75, 70, 65, 60, 60, 60, 55] // 비용: 전체적으로 낮음, 이상치 포함 const 비용 = [80, 78, 76, 74, 72, 70, 68, 66, 64, 62, 50] // 50은 이상치 // 일정: 분산 큼, 이상치 포함 const 일정 = [90, 88, 86, 84, 82, 80, 78, 76, 74, 60, 100] // 100은 이상치 const categories = this.sampleCategories const groups = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K'] const all: any[] = [] categories.forEach((cat, i) => { let arr: number[] = [] switch (cat) { case '생산성': arr = 생산성 break case '품질': arr = 품질 break case '안전': arr = 안전 break case '환경': arr = 환경 break case '비용': arr = 비용 break case '일정': arr = 일정 break } arr.forEach((v, idx) => { all.push({ group: groups[idx] ?? `G${idx + 1}`, category: cat, value: v }) }) }) return all } private get sampleRadarData(): any[] { // 카테고리별로 min, max, avg, A(자신의 그룹)만 반환 const categories = this.sampleCategories const data = this.sampleSeriesData const minData: any = { group: 'Min' } const maxData: any = { group: 'Max' } const avgData: any = { group: 'Avg' } const aData: any = { group: 'A' } categories.forEach(category => { const values = data.filter(d => d.category === category).map(d => d.value) minData[category] = Math.min(...values) maxData[category] = Math.max(...values) avgData[category] = values.reduce((a, b) => a + b, 0) / values.length aData[category] = data.find(d => d.category === category && d.org === 'A')?.value ?? avgData[category] }) // kpi-radar-chart가 기대하는 구조로 변환: [{org, category, value} ...] const result: any[] = [] categories.forEach(category => { result.push({ org: 'Min', category, value: minData[category] }) result.push({ org: 'Max', category, value: maxData[category] }) result.push({ org: 'Avg', category, value: avgData[category] }) result.push({ org: 'A', category, value: aData[category] }) }) return result } private get sampleRadarCategories(): string[] { return this.sampleCategories } private get sampleRadarGroups(): string[] { return this.sampleGroups } private get sampleBoxplotData(): any[] { // 카테고리별로 그룹별 value의 분포 계산 const categories = this.sampleCategories const data = this.sampleSeriesData return categories.map(category => { const values = data.filter(d => d.category === category).map(d => d.value) const sorted = [...values].sort((a, b) => a - b) const min = sorted[0] const max = sorted[sorted.length - 1] const mean = values.reduce((a, b) => a + b, 0) / values.length const median = sorted.length % 2 === 0 ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 : sorted[Math.floor(sorted.length / 2)] const q1 = sorted[Math.floor(sorted.length / 4)] const q3 = sorted[Math.floor((sorted.length * 3) / 4)] // value: A그룹의 실제값(강조) const value = data.find(d => d.category === category && d.org === 'A')?.value ?? mean return { org: category, // x축: 카테고리 min, q1, median, q3, max, mean, value } }) } private get sampleBoxplotGroups(): string[] { return this.sampleCategories } private get sampleCurrentGroup() { return 'A' } // 현재 월을 YYYY-MM 형식으로 반환 private get currentMonth(): string { const now = new Date() const year = now.getFullYear() const month = String(now.getMonth() + 1).padStart(2, '0') return `${year}-${month}` } // 필터링된 KPI 통계 데이터 private get filteredKpiStatistics(): any[] { if (!this.kpiStatistics || this.kpiStatistics.length === 0) return [] return this.kpiStatistics.filter(stat => stat.periodType === 'MONTH' && stat.valueDate === this.selectedValueDate) } // 사용 가능한 기간 타입들 (MONTH로 고정) private get availablePeriodTypes(): string[] { return ['MONTH'] } // MONTH 기간에 사용 가능한 날짜들 private get availableValueDates(): string[] { if (!this.kpiStatistics || this.kpiStatistics.length === 0) return [] const valueDates = new Set() this.kpiStatistics.forEach(stat => { if (stat.periodType === 'MONTH' && stat.valueDate) { valueDates.add(stat.valueDate) } }) return Array.from(valueDates).sort().reverse() // 최신 날짜부터 정렬 } // 통계 요약 정보 private get statisticsSummary(): any { const filteredStats = this.filteredKpiStatistics if (filteredStats.length === 0) return null const totalKpis = filteredStats.length const categories = new Set(filteredStats.map(s => s.kpi?.category?.name).filter(Boolean)) const totalCategories = categories.size const means = filteredStats.map(s => s.mean || 0).filter(v => v > 0) const medians = filteredStats.map(s => s.median || 0).filter(v => v > 0) const stdDevs = filteredStats.map(s => s.standardDeviation || 0).filter(v => v > 0) const avgMean = means.length > 0 ? means.reduce((a, b) => a + b, 0) / means.length : 0 const avgMedian = medians.length > 0 ? medians.reduce((a, b) => a + b, 0) / medians.length : 0 const avgStdDev = stdDevs.length > 0 ? stdDevs.reduce((a, b) => a + b, 0) / stdDevs.length : 0 return { totalKpis, totalCategories, avgMean: avgMean.toFixed(2), avgMedian: avgMedian.toFixed(2), avgStdDev: avgStdDev.toFixed(2), periodType: this.selectedPeriodType, valueDate: this.selectedValueDate } } // 기간 타입 변경 핸들러 (사용하지 않음 - MONTH로 고정) private _onPeriodTypeChange(event: Event) { // MONTH로 고정되어 있으므로 변경하지 않음 } // 날짜 변경 핸들러 private _onValueDateChange(event: Event) { const target = event.target as HTMLSelectElement this.selectedValueDate = target.value } // 실제 KPI 통계 데이터를 기반으로 한 레이더 차트 데이터 private get realKpiRadarData(): any[] { const filteredStats = this.filteredKpiStatistics if (filteredStats.length === 0) return [] // 카테고리별로 통계 데이터 그룹화 const categoryStats = new Map() filteredStats.forEach(stat => { if (stat.kpi?.category?.name) { const categoryName = stat.kpi.category.name if (!categoryStats.has(categoryName)) { categoryStats.set(categoryName, []) } categoryStats.get(categoryName)!.push(stat) } }) // 각 카테고리별로 평균, 중앙값, 표준편차 계산 const result: any[] = [] const categories = Array.from(categoryStats.keys()) categories.forEach(category => { const stats = categoryStats.get(category)! const means = stats.map(s => s.mean || 0).filter(v => v > 0) const medians = stats.map(s => s.median || 0).filter(v => v > 0) const stdDevs = stats.map(s => s.standardDeviation || 0).filter(v => v > 0) if (means.length > 0) { result.push({ group: '평균', category, value: means.reduce((a, b) => a + b, 0) / means.length }) } if (medians.length > 0) { result.push({ group: '중앙값', category, value: medians.reduce((a, b) => a + b, 0) / medians.length }) } if (stdDevs.length > 0) { result.push({ group: '표준편차', category, value: stdDevs.reduce((a, b) => a + b, 0) / stdDevs.length }) } }) return result } private get realKpiRadarCategories(): string[] { const filteredStats = this.filteredKpiStatistics if (filteredStats.length === 0) return [] const categories = new Set() filteredStats.forEach(stat => { if (stat.kpi?.category?.name) { categories.add(stat.kpi.category.name) } }) return Array.from(categories) } private get realKpiRadarGroups(): string[] { return ['평균', '중앙값', '표준편차'] } // 실제 KPI 통계 데이터를 기반으로 한 박스플롯 데이터 private get realKpiBoxplotData(): any[] { const filteredStats = this.filteredKpiStatistics if (filteredStats.length === 0) return [] // 카테고리별로 통계 데이터 그룹화 const categoryStats = new Map() filteredStats.forEach(stat => { if (stat.kpi?.category?.name) { const categoryName = stat.kpi.category.name if (!categoryStats.has(categoryName)) { categoryStats.set(categoryName, []) } categoryStats.get(categoryName)!.push(stat) } }) const result: any[] = [] const categories = Array.from(categoryStats.keys()) categories.forEach(category => { const stats = categoryStats.get(category)! // 각 KPI의 통계값들을 수집 const allMeans = stats.map(s => s.mean || 0).filter(v => v > 0) const allMedians = stats.map(s => s.median || 0).filter(v => v > 0) const allMins = stats.map(s => s.minimum || 0).filter(v => v > 0) const allMaxs = stats.map(s => s.maximum || 0).filter(v => v > 0) const allQ1s = stats.map(s => s.percentile25 || 0).filter(v => v > 0) const allQ3s = stats.map(s => s.percentile75 || 0).filter(v => v > 0) if (allMeans.length > 0) { const sortedMeans = [...allMeans].sort((a, b) => a - b) const min = sortedMeans[0] const max = sortedMeans[sortedMeans.length - 1] const mean = allMeans.reduce((a, b) => a + b, 0) / allMeans.length const median = allMedians.length > 0 ? allMedians.reduce((a, b) => a + b, 0) / allMedians.length : mean const q1 = sortedMeans[Math.floor(sortedMeans.length / 4)] const q3 = sortedMeans[Math.floor((sortedMeans.length * 3) / 4)] result.push({ group: category, min, q1, median, q3, max, mean, value: mean // 현재 값으로 평균 사용 }) } }) return result } private get realKpiBoxplotGroups(): string[] { const filteredStats = this.filteredKpiStatistics if (filteredStats.length === 0) return [] const categories = new Set() filteredStats.forEach(stat => { if (stat.kpi?.category?.name) { categories.add(stat.kpi.category.name) } }) return Array.from(categories) } private get realKpiCurrentGroup() { return '평균' } connectedCallback() { super.connectedCallback() this.fetchCategories() this.fetchKpiStatistics() } pageUpdated(changes: any, lifecycle: any) { if (this.active) { this.fetchCategories() this.fetchKpiStatistics() } } async fetchCategories() { this.loading = true this.error = '' try { const response = await client.query({ query: gql` query { kpiCategories: kpisLevel1 { id name kpis: children { id name value { value valueDate } targetValue unit grades vizType vizMeta histories(limit: 1) { version updatedAt updater { name } } } } kpiAlerts { id kpi { id } message level createdAt } } ` }) this.categories = response.data.kpiCategories || [] this.alerts = response.data.kpiAlerts || [] } catch (e) { this.error = 'KPI 카테고리 데이터를 불러오지 못했습니다.' } finally { this.loading = false } } async fetchKpiStatistics() { this.statisticsLoading = true try { const response = await client.query({ query: gql` query { kpiStatistics { items { id valueDate periodType count sum range mean median minimum maximum standardDeviation variance percentile25 percentile75 iqr lowerFence upperFence additionalStatistics metadata kpi { id name unit category: parent { id name } } } } } ` }) this.kpiStatistics = response.data.kpiStatistics.items || [] // 현재 월을 기본값으로 설정 const currentMonth = this.currentMonth const availableDates = this.availableValueDates if (availableDates.includes(currentMonth)) { // 현재 월 데이터가 있으면 현재 월로 설정 this.selectedValueDate = currentMonth } else if (availableDates.length > 0) { // 현재 월 데이터가 없으면 가장 최근 데이터로 설정 this.selectedValueDate = availableDates[0] } else { // 데이터가 없으면 현재 월로 설정 this.selectedValueDate = currentMonth } } catch (e) { console.error('KPI 통계 데이터를 불러오지 못했습니다:', e) } finally { this.statisticsLoading = false } } async openHistoryModal(kpi) { // 전체 이력 fetch (limit 없이) try { const response = await client.query({ query: gql` query ($kpiId: String!) { kpi(id: $kpiId) { name histories { version updatedAt updater { name } } } } `, variables: { kpiId: kpi.id } }) this.modalHistories = response.data.kpi.histories || [] this.modalKpiName = response.data.kpi.name || '' this.showHistoryModal = true } catch (e) { alert('이력 데이터를 불러오지 못했습니다.') } } closeHistoryModal() { this.showHistoryModal = false this.modalHistories = [] this.modalKpiName = '' } _renderKpiValue(kpi: any) { const kpiValue = kpi.value?.value ?? 0 const targetValue = kpi.targetValue ?? 100 const unit = kpi.unit ?? '' const vizType = kpi.vizType || 'CARD' const vizMeta = kpi.vizMeta || {} const color = vizMeta.color || '#3a3ad6' const icon = vizMeta.icon || 'trending_up' const minValue = vizMeta.minValue || 0 const maxValue = vizMeta.maxValue || 100 const decimalPlaces = vizMeta.decimalPlaces || 0 const formattedValue = typeof kpiValue === 'number' ? kpiValue.toFixed(decimalPlaces) : kpiValue switch (vizType) { case 'GAUGE': const gaugePercentage = Math.min(((kpiValue - minValue) / (maxValue - minValue)) * 100, 100) return html`
${formattedValue}${unit}
` case 'PROGRESS': const progressPercentage = Math.min(((kpiValue - minValue) / (maxValue - minValue)) * 100, 100) return html`
${formattedValue}${unit}
` case 'ICON': return html`
${icon}
${formattedValue}${unit}
` case 'THERMOMETER': const thermoPercentage = Math.min(((kpiValue - minValue) / (maxValue - minValue)) * 100, 100) return html`
${formattedValue}${unit}
` case 'SPEEDOMETER': const speedPercentage = Math.min(((kpiValue - minValue) / (maxValue - minValue)) * 100, 100) const angle = (speedPercentage / 100) * 180 - 90 // -90도에서 90도까지 return html`
${formattedValue}${unit}
` case 'BULLET': const bulletPercentage = Math.min(((kpiValue - minValue) / (maxValue - minValue)) * 100, 100) const targetPercentage = Math.min(((targetValue - minValue) / (maxValue - minValue)) * 100, 100) return html`
${formattedValue}${unit}
` case 'TEXT': return html`
${formattedValue}${unit}
` case 'BADGE': return html`
${formattedValue}${unit}
` default: // CARD, BAR, LINE, PIE, DONUT, RADAR, TABLE return html`
${formattedValue}${unit}
` } } get context() { return { title: 'KPI 대시보드', description: '조직 KPI 실적, 등급, 이력, 시각화 등 KPI 전용 대시보드' } as any } render() { if (this.loading) return nothing if (this.error) return html`
${this.error}
` return html`
그룹별 KPI 비교 (Radar)
Radar 차트는 각 카테고리별로 최소(Min), 최대(Max), 평균(Avg), 그리고 이 프로젝트의 값을 한눈에 비교할 수 있도록 시각화합니다.
진한 파란색 다각형이 이 프로젝트의 성과이며, 회색 다각형은 기준값(최소/최대/평균)입니다.
그룹별 분포 (Boxplot)
Boxplot(박스플롯)은 각 카테고리별로 값의 분포(최소, 1사분위, 중앙값, 3사분위, 최대, 평균, 이상치 등)를 보여줍니다.
박스는 중앙 50% 구간, 수염은 전체 범위, 굵은 검정색 가로선은 중앙값(메디안), 주황색 원은 평균값(Mean)을 의미합니다.
이 프로젝트의 값진한 오렌지색 원으로 별도 강조되어 표시되며, 중앙값/평균과 다를 수 있습니다.
MONTH (월별)
${this.statisticsSummary ? html`
KPI: ${this.statisticsSummary.totalKpis}개 카테고리: ${this.statisticsSummary.totalCategories}개 평균: ${this.statisticsSummary.avgMean} 중앙값: ${this.statisticsSummary.avgMedian} 표준편차: ${this.statisticsSummary.avgStdDev}
` : nothing}
실제 KPI 통계 비교 (Radar)
${this.statisticsLoading ? html`
통계 데이터 로딩 중...
` : this.realKpiRadarData.length > 0 ? html`` : html`
선택된 기간에 통계 데이터가 없습니다.
`}
실제 KPI 통계 Radar 차트는 실제 KPIStatistic 데이터를 기반으로 각 카테고리별 평균, 중앙값, 표준편차를 비교합니다.
평균은 산술평균, 중앙값은 50분위수, 표준편차는 데이터 분산 정도를 나타냅니다.
실제 KPI 통계 분포 (Boxplot)
${this.statisticsLoading ? html`
통계 데이터 로딩 중...
` : this.realKpiBoxplotData.length > 0 ? html`` : html`
선택된 기간에 통계 데이터가 없습니다.
`}
실제 KPI 통계 Boxplot은 실제 KPIStatistic 데이터를 기반으로 각 카테고리별 통계값의 분포를 보여줍니다.
각 카테고리의 평균값들을 기준으로 분포를 계산하여, 카테고리 간 통계적 특성을 비교할 수 있습니다.

3레벨 KPI 분석

실제 KPIStatistic 데이터를 기반으로 한 다층적 분석

📊 1레벨: 그룹 총 스코어

📈 2레벨: 카테고리 비교 분석

🔍 3레벨: 개별 KPI 상세 분석

${this.showHistoryModal ? html`
${this.modalKpiName} 변경 이력
    ${this.modalHistories.length === 0 ? html`
  • 이력이 없습니다.
  • ` : this.modalHistories.map( h => html`
  • v${h.version} (${h.updatedAt?.slice(0, 10) ?? ''} ${h.updater?.name ?? ''})
  • ` )}
` : nothing} ${this.categories.map( cat => html`
${cat.name}
${(cat.kpis || []).map( kpi => html`
${kpi.name}
${this._renderKpiValue(kpi)}
목표: ${kpi.targetValue ?? '-'}${kpi.unit ?? ''}
등급: ${(() => { const grades = kpi.grades || [] const kpiValue = kpi.value?.value if (grades.length > 5) { // 5개를 넘으면 현재 값에 해당하는 grade만 표시 const currentGrade = grades.find(g => kpiValue >= g.minValue && kpiValue <= g.maxValue) if (currentGrade) { return html`${currentGrade.name}(${currentGrade.minValue}~${currentGrade.maxValue}${kpi.unit ?? ''}, ${currentGrade.score ?? ''}점)` } else { return html`등급 없음` } } else { // 5개 이하면 전체 리스트 표시하되 현재 등급은 강조 return grades.map(g => { const isCurrentGrade = kpiValue >= g.minValue && kpiValue <= g.maxValue return html`${g.name}(${g.minValue}~${g.maxValue}${kpi.unit ?? ''}, ${g.score ?? ''}점)` }) } })()}
최근 변경: ${kpi.histories && kpi.histories.length > 0 ? html`v${kpi.histories[0].version} (${kpi.histories[0].updatedAt?.slice(0, 10) ?? ''} ${kpi.histories[0].updater?.name ?? ''})` : html`없음`}
${this.alerts.filter(a => a.kpi?.id === kpi.id).length > 0 ? this.alerts .filter(a => a.kpi?.id === kpi.id) .map( a => html`${a.message}` ) : html`이상/경고 없음`}
` )}
` )}
` } }