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._onPeriodChange(e.target.value)} style="width: 120px;" > this._onTargetDateChange(e.target.value)} style="width: 150px;" > (this.valueDate = e.target.value)} style="width: 150px;" readonly > refresh 새로고침
${this.statisticFields.map( field => html` ` )} ${this.kpis.map( kpi => html` ${this.statisticFields.map( field => html` ` )} ` )}
KPI명
${field.label}
${field.name}
${kpi.kpi.name} ${kpi.periodType} 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() } }