import '@material/web/icon/icon.js'
import '@material/web/button/elevated-button.js'
import '@material/web/textfield/outlined-text-field.js'
import { CommonButtonStyles, CommonHeaderStyles, ScrollbarStyles } from '@operato/styles'
import { PageView } from '@operato/shell'
import { css, html } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import { ScopedElementsMixin } from '@open-wc/scoped-elements'
import { client } from '@operato/graphql'
import { i18next, localize } from '@operato/i18n'
import { notify } from '@operato/layout'
import gql from 'graphql-tag'
interface KpiStatisticData { kpi: { id: string
name: string
}
valueDate: string
periodType: string
statistics: { count?: number
sum?: number
range?: number
mean?: number
median?: number
minimum?: number
maximum?: number
standardDeviation?: number
variance?: number
percentile25?: number
percentile75?: number
iqr?: number
lowerFence?: number
upperFence?: number
isDirty?: boolean
}
}
interface EditorCell { field: string
value: number | null
isEditable: boolean
isHighlighted: boolean
}
@customElement('kpi-statistic-editor-page')
export class KpiStatisticEditorPage extends localize(i18next)(ScopedElementsMixin(PageView)) { static styles = [
CommonHeaderStyles,
ScrollbarStyles,
css`
:host { display: flex;
flex-direction: column;
padding: 20px;
overflow-x: auto;
}
.header { display: flex;
gap: 16px;
align-items: center;
margin-bottom: 20px;
padding: 16px;
background: var(--md-sys-color-surface-container);
border-radius: 8px;
}
.controls { display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.table-container { flex: 1;
overflow: auto;
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
}
table { width: 100%;
border-collapse: collapse;
min-width: max-content;
}
th { background: var(--md-sys-color-surface-container-low);
font-weight: 500;
padding: 8px 12px;
border: 1px solid var(--md-sys-color-outline-variant);
min-width: 120px;
height: 60px;
vertical-align: middle;
text-align: center;
}
td { padding: 8px 12px;
border: 1px solid var(--md-sys-color-outline-variant);
min-width: 120px;
height: 60px;
text-align: center;
vertical-align: middle;
}
.kpi-header { position: sticky;
left: 0;
top: 0;
z-index: 3;
min-width: 200px;
text-align: left;
}
.field-header { position: sticky;
left: 0;
top: 0;
z-index: 2;
min-width: 200px;
text-align: center;
}
.kpi-name { position: sticky;
left: 0;
background: var(--md-sys-color-surface);
font-weight: 500;
min-width: 200px;
text-align: left;
z-index: 2;
}
tr:hover { background: var(--md-sys-color-surface-container-high);
}
td.editable-cell { cursor: pointer;
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
td.editable-cell:hover { background: var(--md-sys-color-primary-container-high);
}
td.highlighted-cell { background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
font-weight: 500;
}
td.highlighted-cell:hover { background: var(--md-sys-color-secondary-container-high);
}
td.disabled-cell { background: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
cursor: not-allowed;
}
.value-input { width: 100%;
text-align: center;
border: none;
background: transparent;
color: inherit;
font-size: 12px;
padding: 2px;
}
.value-input:focus { outline: 2px solid var(--md-sys-color-primary);
border-radius: 4px;
}
.period-badge { font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-on-tertiary-container);
margin-left: 8px;
}
.loading { display: flex;
justify-content: center;
align-items: center;
height: 200px;
color: var(--md-sys-color-on-surface-variant);
}
.error { color: var(--md-sys-color-error);
padding: 16px;
text-align: center;
}
.legend { display: flex;
gap: 16px;
margin-top: 16px;
font-size: 12px;
}
.legend-item { display: flex;
align-items: center;
gap: 4px;
}
.legend-color { width: 16px;
height: 16px;
border-radius: 2px;
}
.field-group { display: flex;
flex-direction: column;
gap: 4px;
}
.field-name { font-size: 10px;
color: var(--md-sys-color-on-surface-variant);
font-weight: 500;
}
.field-value { font-size: 12px;
font-weight: 500;
}
`
]
@property({ type: String }) periodType: string = 'MONTH'
@property({ type: String }) valueDate: string = ''
@property({ type: String }) targetDate: string = '' // 기준 날짜 (예: 2025-08-03)
@state() private kpis: KpiStatisticData[] = []
@state() private loading: boolean = false
@state() private error: string = ''
@state() private editingCell: { kpi: { id: string }; field: string } | null = null
@state() private _existingStatistics: any[] = []
// 통계 필드 정의
private readonly statisticFields = [
{ name: 'count', label: 'Count', group: 'basic' },
{ name: 'sum', label: 'Sum', group: 'basic' },
{ name: 'range', label: 'Range', group: 'basic' },
{ name: 'mean', label: 'Mean', group: 'central' },
{ name: 'median', label: 'Median', group: 'central' },
{ name: 'minimum', label: 'Min', group: 'range' },
{ name: 'maximum', label: 'Max', group: 'range' },
{ name: 'standardDeviation', label: 'Std Dev', group: 'dispersion' },
{ name: 'variance', label: 'Variance', group: 'dispersion' },
{ name: 'percentile25', label: 'P25', group: 'percentile' },
{ name: 'percentile75', label: 'P75', group: 'percentile' },
{ name: 'iqr', label: 'IQR', group: 'percentile' },
{ name: 'lowerFence', label: 'Lower Fence', group: 'fence' },
{ name: 'upperFence', label: 'Upper Fence', group: 'fence' }
]
get context() { return { title: i18next.t('title.kpi statistic editor'),
actions: [
{ title: i18next.t('button.save'),
action: this._saveStatistics.bind(this),
...CommonButtonStyles.save
},
{ title: i18next.t('button.cancel'),
action: this._cancel.bind(this),
...CommonButtonStyles.cancel
}
]
}
}
render() { if (this.loading) { return html`
hourglass_empty
데이터를 불러오는 중...
`
}
if (this.error) { return html`
error
${this.error}
`
}
return html`
${this.statisticFields.map(
field => html`
`
)}
${this.kpis.map(
kpi => html`
|
${kpi.kpi.name}
${kpi.periodType}
|
${this.statisticFields.map(
field => html`
this._startEdit(kpi.kpi.id, field.name)}
>
${this._renderCellContent(kpi, field.name)}
|
`
)}
`
)}
`
}
private _getCellClass(kpi: KpiStatisticData, fieldName: string): string { const isEditable = this._isFieldEditable(fieldName)
const isHighlighted = this._isFieldHighlighted(fieldName)
if (!isEditable) { return 'disabled-cell'
}
if (isHighlighted) { return 'highlighted-cell'
}
return 'editable-cell'
}
private _isFieldEditable(fieldName: string): boolean { // 모든 필드를 편집 가능하게 설정 (필요에 따라 제한 가능)
return true
}
private _isFieldHighlighted(fieldName: string): boolean { // 중요 필드들을 하이라이트
const importantFields = ['count', 'mean', 'median', 'minimum', 'maximum', 'standardDeviation']
return importantFields.includes(fieldName)
}
private _renderCellContent(kpi: KpiStatisticData, fieldName: string) { const isEditing = this.editingCell?.kpi?.id === kpi.kpi.id && this.editingCell?.field === fieldName
const value = kpi.statistics[fieldName]
if (isEditing) { return html`
this._finishEdit(kpi.kpi.id, fieldName, parseFloat(e.target.value) || null)}
@keydown=${(e: any) => e.key === 'Enter' && e.target.blur()}
autofocus
/>
`
}
return html`
${value !== null && value !== undefined ? value.toLocaleString() : '클릭하여 입력'}
`
}
private _startEdit(kpiId: string, fieldName: string) { this.editingCell = { kpi: { id: kpiId }, field: fieldName }
}
private _finishEdit(kpiId: string, fieldName: string, value: number | null) { const kpi = this.kpis.find(k => k.kpi.id === kpiId)
if (kpi) { const originalValue = this._findExistingStatistic(kpiId)?.[fieldName]
const isChanged = originalValue !== value
kpi.statistics[fieldName] = value
kpi.statistics.isDirty = isChanged
}
this.editingCell = null
}
private async _loadData() { if (!this.valueDate) { this.error = '값 날짜를 입력해주세요.'
return
}
this.loading = true
this.error = ''
try { // KPI 목록 조회
const kpisResponse = await client.query({ query: gql`
query ($filters: [Filter!]) { kpis(filters: $filters) { items { id
name
periodType
active
category: parent { id
name
}
}
total
}
}
`,
variables: { filters: [{ name: 'active', operator: 'eq', value: true }]
}
})
// 기존 KPI Statistic 데이터 조회
const statisticsResponse = await client.query({ query: gql`
query ($filters: [Filter!]) { kpiStatistics(filters: $filters) { items { id
kpi { id
name
}
valueDate
periodType
count
sum
range
mean
median
minimum
maximum
standardDeviation
variance
percentile25
percentile75
iqr
lowerFence
upperFence
additionalStatistics
metadata
}
total
}
}
`,
variables: { filters: [
{ name: 'valueDate', operator: 'eq', value: this.valueDate },
{ name: 'periodType', operator: 'eq', value: this.periodType }
]
}
})
// KPI 목록을 기준으로 데이터 구성 (카테고리별로 필터링)
console.log('KPI 원본 데이터:', kpisResponse.data.kpis.items)
const filteredKpis = kpisResponse.data.kpis.items
console.log('모든 KPI 포함:', filteredKpis)
console.log('필터링된 KPI:', filteredKpis)
this.kpis = filteredKpis.map((kpi: any) => ({ kpi: kpi,
valueDate: this.valueDate,
periodType: this.periodType,
statistics: { count: null,
sum: null,
range: null,
mean: null,
median: null,
minimum: null,
maximum: null,
standardDeviation: null,
variance: null,
percentile25: null,
percentile75: null,
iqr: null,
lowerFence: null,
upperFence: null,
isDirty: false
}
}))
// 기존 KPI Statistic 데이터 저장
this._existingStatistics = statisticsResponse.data.kpiStatistics.items
console.log('기존 통계 데이터:', this._existingStatistics)
// 기존 통계 데이터를 해당 KPI에 매핑
statisticsResponse.data.kpiStatistics.items.forEach((statistic: any) => { const kpi = this.kpis.find(k => k.kpi.id === statistic.kpi.id) // KPI ID로 매칭
console.log(`통계 데이터 매핑: statistic.kpi.id=${statistic.kpi.id}, 찾은 KPI:`, kpi?.kpi.name || '없음')
if (kpi) { kpi.statistics = { count: statistic.count,
sum: statistic.sum,
range: statistic.range,
mean: statistic.mean,
median: statistic.median,
minimum: statistic.minimum,
maximum: statistic.maximum,
standardDeviation: statistic.standardDeviation,
variance: statistic.variance,
percentile25: statistic.percentile25,
percentile75: statistic.percentile75,
iqr: statistic.iqr,
lowerFence: statistic.lowerFence,
upperFence: statistic.upperFence,
isDirty: false
}
}
})
console.log('KPI 총 개수:', kpisResponse.data.kpis.total)
console.log('KPI 목록:', kpisResponse.data.kpis.items)
console.log('기존 통계 데이터:', this._existingStatistics)
console.log('구성된 KPI 통계 데이터:', this.kpis)
console.log('최종 KPI 배열 길이:', this.kpis.length)
this.requestUpdate()
} catch (error) { console.error('데이터 로드 중 오류:', error)
this.error = '데이터를 불러오는 중 오류가 발생했습니다.'
} finally { this.loading = false
}
}
private async _saveStatistics() { try { const patches: any[] = []
this.kpis.forEach(kpi => { // dirty 상태인 데이터만 처리
if (kpi.statistics.isDirty) { const existingStatistic = this._findExistingStatistic(kpi.kpi.id)
if (existingStatistic) { // 기존 데이터 업데이트
const { isDirty, ...statisticsWithoutDirty } = kpi.statistics
patches.push({ id: existingStatistic.id,
...statisticsWithoutDirty,
cuFlag: 'M'
})
} else { // 새로운 데이터 생성
const { isDirty, ...statisticsWithoutDirty } = kpi.statistics
patches.push({ kpi: { id: kpi.kpi.id
},
valueDate: kpi.valueDate,
periodType: kpi.periodType,
...statisticsWithoutDirty,
additionalStatistics: {},
metadata: { calculationMethod: 'manual',
lastCalculated: new Date(),
dataCount: kpi.statistics.count || 0
},
cuFlag: '+'
})
}
}
})
if (patches.length === 0) { notify({ message: '저장할 데이터가 없습니다.' })
return
}
const response = await client.mutate({ mutation: gql`
mutation ($patches: [KpiStatisticPatch!]!) { updateMultipleKpiStatistic(patches: $patches) { id
kpi { id
name
}
valueDate
periodType
}
}
`,
variables: { patches }
})
if (!response.errors) { // 저장 후 dirty 상태 해제
this.kpis.forEach(kpi => { kpi.statistics.isDirty = false
})
notify({ message: 'KPI 통계값이 성공적으로 저장되었습니다.' })
this.requestUpdate()
}
} catch (error) { console.error('저장 중 오류:', error)
notify({ message: '저장 중 오류가 발생했습니다.' })
}
}
private _findExistingStatistic(kpiId: string) { return this._existingStatistics?.find((s: any) => s.kpi === kpiId)
}
private _onTargetDateChange(targetDate: string) { this.targetDate = targetDate
this._calculateDateRange()
}
private _onPeriodChange(periodType: string) { this.periodType = periodType
this._calculateDateRange()
}
private _calculateDateRange() { if (!this.targetDate || !this.periodType) return
const target = new Date(this.targetDate)
let valueDate: string
switch (this.periodType) { case 'DAY':
valueDate = target.toISOString().split('T')[0]
break
case 'WEEK':
// 해당 주의 월요일
const dayOfWeek = target.getDay()
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
const monday = new Date(target)
monday.setDate(target.getDate() - daysToMonday)
valueDate = monday.toISOString().split('T')[0]
break
case 'MONTH':
valueDate = `${target.getFullYear()}-${String(target.getMonth() + 1).padStart(2, '0')}`
break
case 'QUARTER':
const quarter = Math.floor(target.getMonth() / 3) + 1
valueDate = `${target.getFullYear()}-Q${quarter}`
break
case 'YEAR':
valueDate = target.getFullYear().toString()
break
default:
valueDate = target.toISOString().split('T')[0]
}
this.valueDate = valueDate
}
private _cancel() { this.editingCell = null
this._loadData() // 원본 데이터로 복원
}
async pageInitialized(lifecycle: any) { // 기본값 설정 - 현재 날짜를 기준으로
if (!this.targetDate) { this.targetDate = new Date().toLocaleDateString('sv-SE')
}
// 기간 유형에 따라 날짜 범위 계산
this._calculateDateRange()
await this._loadData()
}
}