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 KpiValueData { kpiId: string kpiName: string periodType: string values: { [date: string]: { value: number | null; score?: number; isDirty?: boolean } } } interface EditorCell { date: string value: number | null score?: number isEditable: boolean isHighlighted: boolean } @customElement('kpi-value-editor-page') export class KpiValueEditorPage 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; } .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: 80px; height: 120px; vertical-align: middle; } td { padding: 8px 12px; border: 1px solid var(--md-sys-color-outline-variant); min-width: 80px; height: 60px; text-align: right; vertical-align: middle; } .kpi-header { position: sticky; left: 0; top: 0; z-index: 3; } .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); } th.date-header { position: sticky; top: 0; z-index: 2; text-align: center; font-size: 11px; color: var(--md-sys-color-on-surface-variant); writing-mode: vertical-rl; text-orientation: mixed; min-height: 120px; display: flex; align-items: center; justify-content: center; } 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-type-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; } ` ] @property({ type: String }) kpiOrgScopeId: string = '' @property({ type: String }) org: string = '' // legacy support @property({ type: String }) startDate: string = '' @property({ type: String }) endDate: string = '' @state() private kpis: KpiValueData[] = [] @state() private dates: string[] = [] @state() private loading: boolean = false @state() private error: string = '' @state() private editingCell: { kpiId: string; date: string } | null = null @state() private _existingValues: any[] = [] get context() { return { title: i18next.t('title.kpi value editor'), actions: [ { title: i18next.t('button.save'), action: this._saveValues.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.org = e.target.value)} style="width: 150px;" > (this.startDate = e.target.value)} style="width: 150px;" > (this.endDate = e.target.value)} style="width: 150px;" > refresh 새로고침
${this.dates.map( date => html` ` )} ${this.kpis.map( kpi => html` ${this.dates.map( date => html` ` )} ` )}
KPI명 ${this._formatDate(date)}
${kpi.kpiName} ${kpi.periodType} this._startEdit(kpi.kpiId, date)} > ${this._renderCellContent(kpi, date)}
편집 가능
하이라이트 (PeriodType에 따라)
편집 불가
` } private _renderKpiCells(kpi: KpiValueData) { const cells = this._getCellsForKpi(kpi) return cells.map(cell => { const isEditing = this.editingCell?.kpiId === kpi.kpiId && this.editingCell?.date === cell.date const cellClass = this._getCellClass(cell) return html`
this._startEdit(kpi.kpiId, cell.date)}> ${isEditing ? html` this._finishEdit(kpi.kpiId, cell.date, parseFloat(e.target.value) || 0)} @keydown=${(e: any) => e.key === 'Enter' && e.target.blur()} autofocus /> ` : html` ${cell.value !== null ? cell.value.toLocaleString() : '-'} `}
` }) } private _getCellsForKpi(kpi: KpiValueData): EditorCell[] { const cells: EditorCell[] = [] const periodType = kpi.periodType this.dates.forEach(date => { const isEditable = this._isDateEditableForPeriodType(date, periodType) const isHighlighted = this._isDateHighlightedForPeriodType(date, periodType) cells.push({ date, value: kpi.values[date]?.value || null, score: kpi.values[date]?.score, isEditable, isHighlighted }) }) return cells } private _isDateEditableForPeriodType(date: string, periodType: string): boolean { const targetDate = new Date(date) switch (periodType) { case 'DAY': return true // 모든 날짜 편집 가능 case 'WEEK': // 해당 주의 첫 번째 날짜만 편집 가능 const weekStart = new Date(targetDate) weekStart.setDate(targetDate.getDate() - targetDate.getDay()) return date === weekStart.toISOString().split('T')[0] case 'MONTH': // 해당 월의 첫 번째 날짜만 편집 가능 return targetDate.getDate() === 1 case 'QUARTER': // 해당 분기의 첫 번째 날짜만 편집 가능 const quarterStart = new Date(targetDate.getFullYear(), Math.floor(targetDate.getMonth() / 3) * 3, 1) return date === quarterStart.toISOString().split('T')[0] case 'YEAR': // 해당 연도의 첫 번째 날짜만 편집 가능 const yearStart = new Date(targetDate.getFullYear(), 0, 1) return date === yearStart.toISOString().split('T')[0] default: return true } } private _isDateHighlightedForPeriodType(date: string, periodType: string): boolean { const targetDate = new Date(date) switch (periodType) { case 'DAY': return false // 하이라이트 없음 case 'WEEK': // 해당 주의 모든 날짜 하이라이트 const weekStart = new Date(targetDate) weekStart.setDate(targetDate.getDate() - targetDate.getDay()) const weekEnd = new Date(weekStart) weekEnd.setDate(weekStart.getDate() + 6) return date >= weekStart.toISOString().split('T')[0] && date <= weekEnd.toISOString().split('T')[0] case 'MONTH': // 해당 월의 모든 날짜 하이라이트 const monthStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1) const monthEnd = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0) return date >= monthStart.toISOString().split('T')[0] && date <= monthEnd.toISOString().split('T')[0] case 'QUARTER': // 해당 분기의 모든 날짜 하이라이트 const quarter = Math.floor(targetDate.getMonth() / 3) const quarterStart = new Date(targetDate.getFullYear(), quarter * 3, 1) const quarterEnd = new Date(targetDate.getFullYear(), (quarter + 1) * 3, 0) return date >= quarterStart.toISOString().split('T')[0] && date <= quarterEnd.toISOString().split('T')[0] case 'YEAR': // 해당 연도의 모든 날짜 하이라이트 const yearStart = new Date(targetDate.getFullYear(), 0, 1) const yearEnd = new Date(targetDate.getFullYear(), 11, 31) return date >= yearStart.toISOString().split('T')[0] && date <= yearEnd.toISOString().split('T')[0] default: return false } } private _getCellClass(cell: EditorCell): string { if (!cell.isEditable) { return 'disabled-cell' } if (cell.isHighlighted) { return 'highlighted-cell' } if (cell.isEditable) { return 'editable-cell' } return '' } private _formatDate(date: string): string { const d = new Date(date) return d.toLocaleDateString('ko-KR', { month: 'numeric', day: 'numeric' }) } private _startEdit(kpiId: string, date: string) { this.editingCell = { kpiId, date } } private _finishEdit(kpiId: string, date: string, value: number) { const kpi = this.kpis.find(k => k.kpiId === kpiId) if (kpi) { if (!kpi.values[date]) { kpi.values[date] = { value: 0, isDirty: false } } // 값이 변경되었는지 확인 const originalValue = this._findExistingValue(kpiId, date)?.value const isChanged = originalValue !== value kpi.values[date].value = value kpi.values[date].isDirty = isChanged // score는 저장 시 서버에서 계산되므로 여기서는 제거 delete kpi.values[date].score } this.editingCell = null } private _renderCellContent(kpi: KpiValueData, date: string) { const isEditing = this.editingCell?.kpiId === kpi.kpiId && this.editingCell?.date === date const value = kpi.values[date]?.value const score = kpi.values[date]?.score if (isEditing) { return html` this._finishEdit(kpi.kpiId, date, parseFloat(e.target.value) || 0)} @keydown=${(e: any) => e.key === 'Enter' && e.target.blur()} style="width: 100%; text-align: center; border: none; background: transparent;" autofocus /> ` } return html`
${value !== null && value !== undefined ? value.toLocaleString() : '클릭하여 입력'} ${score !== null && score !== undefined ? html`Score: ${score.toFixed(3)}` : ''}
` } private _calculateScore(value: number, periodType: string): number { // 간단한 score 계산 로직 (0-1 범위) // 실제로는 KPI의 formula나 기준값을 사용해야 함 if (value <= 0) return 0 if (value >= 1000) return 1 return Math.min(value / 1000, 1) } private async _loadData() { if (!this.startDate || !this.endDate) { 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 } total } } `, variables: { filters: [{ name: 'active', operator: 'eq', value: true }] } }) // KPI Value 데이터 조회 const valuesResponse = await client.query({ query: gql` query ($filters: [Filter!]) { kpiValues(filters: $filters) { items { id kpiId valueDate value score kpiOrgScope { id org } } total } } `, variables: { filters: [ ...(this.org ? [{ name: 'org', operator: 'eq', value: this.org }] : []), { name: 'valueDate', operator: 'between', value: [this.startDate, this.endDate] } ] } }) // 날짜 배열 생성 this.dates = this._generateDateArray(this.startDate, this.endDate) console.log('시작일:', this.startDate) console.log('종료일:', this.endDate) console.log('생성된 날짜 배열:', this.dates) // KPI 목록을 기준으로 데이터 구성 this.kpis = kpisResponse.data.kpis.items.map((kpi: any) => ({ kpiId: kpi.id, kpiName: kpi.name, periodType: kpi.periodType, values: {} })) // 기존 KPI Value 데이터 저장 this._existingValues = valuesResponse.data.kpiValues.items // KPI Value 데이터를 해당 KPI에 매핑 valuesResponse.data.kpiValues.items.forEach((value: any) => { const kpi = this.kpis.find(k => k.kpiId === value.kpiId) if (kpi) { kpi.values[value.valueDate] = { value: value.value, score: value.score, isDirty: false } } }) // KPI Value가 없는 KPI도 빈 값으로 초기화 this.kpis.forEach(kpi => { this.dates.forEach(date => { if (!kpi.values[date]) { kpi.values[date] = { value: null, score: undefined, isDirty: false } } }) }) // 디버깅 정보 출력 console.log('KPI 총 개수:', kpisResponse.data.kpis.total) console.log('KPI 목록:', kpisResponse.data.kpis.items) console.log('KPI Value 총 개수:', valuesResponse.data.kpiValues.total) console.log('KPI Value 목록:', valuesResponse.data.kpiValues.items) console.log('구성된 KPI 데이터:', this.kpis) console.log('날짜 배열:', this.dates) // 강제 업데이트 this.requestUpdate() } catch (error) { console.error('데이터 로드 중 오류:', error) this.error = '데이터를 불러오는 중 오류가 발생했습니다.' } finally { this.loading = false } } private _generateDateArray(startDate: string, endDate: string): string[] { const dates: string[] = [] const start = new Date(startDate) const end = new Date(endDate) for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { dates.push(d.toLocaleDateString('sv-SE')) } return dates } private async _saveValues() { try { const patches: any[] = [] this.kpis.forEach(kpi => { Object.entries(kpi.values).forEach(([date, data]) => { // dirty 상태인 데이터만 처리 if (data.isDirty && data.value !== null && data.value !== undefined) { const existingValue = this._findExistingValue(kpi.kpiId, date) if (existingValue) { // 기존 데이터 업데이트 patches.push({ id: existingValue.id, value: data.value, org: this.org, cuFlag: 'M' }) } else { // 새로운 데이터 생성 patches.push({ kpiId: kpi.kpiId, valueDate: date, value: data.value, org: this.org, cuFlag: '+' }) } } }) }) if (patches.length === 0) { notify({ message: '저장할 데이터가 없습니다.' }) return } // 단일 mutation으로 생성과 업데이트 처리 const response = await client.mutate({ mutation: gql` mutation ($patches: [KpiValuePatch!]!) { updateMultipleKpiValue(patches: $patches) { id value score valueDate kpiId } } `, variables: { patches } }) if (!response.errors) { // 서버에서 계산된 score로 UI 업데이트 response.data.updateMultipleKpiValue.forEach((savedValue: any) => { const kpi = this.kpis.find(k => k.kpiId === savedValue.kpiId) if (kpi && kpi.values[savedValue.valueDate]) { kpi.values[savedValue.valueDate].score = savedValue.score kpi.values[savedValue.valueDate].isDirty = false // 저장 후 dirty 상태 해제 } }) notify({ message: 'KPI 값이 성공적으로 저장되었습니다.' }) this.requestUpdate() // UI 강제 업데이트 } } catch (error) { console.error('저장 중 오류:', error) notify({ message: '저장 중 오류가 발생했습니다.' }) } } private _findExistingValue(kpiId: string, date: string) { // 기존 로드된 KPI Value 데이터에서 찾기 return this._existingValues?.find((v: any) => v.kpiId === kpiId && v.valueDate === date) } private _cancel() { // 편집 취소 로직 this.editingCell = null this._loadData() // 원본 데이터로 복원 } async pageInitialized(lifecycle: any) { // 기본값 설정 - 지난 1개월 if (!this.startDate) { const today = new Date() const oneMonthAgo = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate()) this.startDate = oneMonthAgo.toLocaleDateString('sv-SE') } if (!this.endDate) { const today = new Date() this.endDate = today.toLocaleDateString('sv-SE') } // 페이지 초기화 시 자동으로 데이터 로드 await this._loadData() } }